Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

485 lines
20 KiB
Swift

import SwiftUI
import MacToolsPluginKit
struct TranslatorSettingsView: View {
@State private var firstLanguage: TranslatorLanguage
@State private var secondLanguage: TranslatorLanguage
@State private var profiles: [TranslatorProviderProfile]
@State private var apiKeys: [String: String]
@State private var savedLanguagePair: TranslatorLanguagePair
@State private var selectedProfileID: String?
@State private var message: String?
@State private var messageIsError = false
private let localization: PluginLocalization
private let onSave: ([TranslatorProviderProfile], [String: String], TranslatorLanguagePair) -> String?
private let onMakeNewProfile: ([TranslatorProviderProfile]) -> TranslatorProviderProfile
init(
profiles: [TranslatorProviderProfile],
apiKeys: [String: String],
languagePair: TranslatorLanguagePair,
localization: PluginLocalization = PluginLocalization(bundle: .main),
onSave: @escaping ([TranslatorProviderProfile], [String: String], TranslatorLanguagePair) -> String?,
onMakeNewProfile: @escaping ([TranslatorProviderProfile]) -> TranslatorProviderProfile
) {
_firstLanguage = State(initialValue: languagePair.first)
_secondLanguage = State(initialValue: languagePair.second)
_profiles = State(initialValue: profiles.isEmpty ? [TranslatorProviderProfile.defaultProfile(localization: localization)] : profiles)
_apiKeys = State(initialValue: apiKeys)
_savedLanguagePair = State(initialValue: languagePair)
_selectedProfileID = State(initialValue: profiles.first?.id ?? TranslatorProviderProfile.defaultID)
self.localization = localization
self.onSave = onSave
self.onMakeNewProfile = onMakeNewProfile
}
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
languageSection
providerListSection
providerDetailSection
actions
}
}
private var languageSection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(localization.string("settings.language.title", defaultValue: "偏好语言"), icon: "character.book.closed")
VStack(spacing: 0) {
fieldRow(
title: localization.string("settings.language.first.title", defaultValue: "第一语言"),
description: localization.string("settings.language.first.description", defaultValue: "自动识别为其他语言时翻译到这里。")
) {
languagePicker(selection: messageClearing($firstLanguage))
}
PluginSettingsListDivider()
fieldRow(
title: localization.string("settings.language.second.title", defaultValue: "第二语言"),
description: localization.string("settings.language.second.description", defaultValue: "识别为第一语言时翻译到这里。")
) {
languagePicker(selection: messageClearing($secondLanguage))
}
}
.pluginSettingsCardBackground(.host)
}
}
private var providerListSection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
HStack {
sectionHeader(localization.string("settings.providerList.title", defaultValue: "翻译服务"), icon: "network")
Spacer()
Button {
addProfile()
} label: {
Label(localization.string("settings.providerList.add", defaultValue: "添加"), systemImage: "plus")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
VStack(spacing: 0) {
ForEach(profiles) { profile in
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
providerRow(index: index)
if profile.id != profiles.last?.id {
PluginSettingsListDivider()
}
}
}
}
.pluginSettingsCardBackground(.host)
}
}
private var providerDetailSection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(localization.string("settings.providerDetail.title", defaultValue: "服务详情"), icon: "slider.horizontal.3")
if let index = selectedProfileIndex {
VStack(spacing: 0) {
editableFieldRow(
title: localization.string("settings.provider.name.title", defaultValue: "名称"),
description: localization.string("settings.provider.name.description", defaultValue: "显示在翻译结果卡片上。")
) {
TextField("OpenAI", text: binding(for: index, keyPath: \.name))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 280, idealWidth: 320, maxWidth: 420)
}
PluginSettingsListDivider()
editableFieldRow(
title: localization.string("settings.provider.baseURL.title", defaultValue: "服务地址"),
description: localization.string("settings.provider.baseURL.description", defaultValue: "OpenAI 或兼容网关地址。")
) {
TextField("https://api.openai.com", text: binding(for: index, keyPath: \.baseURL))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 280, idealWidth: 320, maxWidth: 420)
}
PluginSettingsListDivider()
editableFieldRow(
title: localization.string("settings.provider.apiKey.title", defaultValue: "接口密钥"),
description: localization.string("settings.provider.apiKey.description", defaultValue: "留空则保留当前钥匙串内容。")
) {
SecureField("sk-...", text: apiKeyBinding(profileID: profiles[index].id))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 280, idealWidth: 320, maxWidth: 420)
}
PluginSettingsListDivider()
editableFieldRow(
title: localization.string("settings.provider.model.title", defaultValue: "模型"),
description: localization.string("settings.provider.model.description", defaultValue: "用于翻译的模型名称。")
) {
TextField("gpt-5.4-mini", text: binding(for: index, keyPath: \.model))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 280, idealWidth: 320, maxWidth: 420)
}
PluginSettingsListDivider()
promptEditor(index: index)
}
.pluginSettingsCardBackground(.host)
} else {
Text(localization.string("settings.providerDetail.empty", defaultValue: "请选择一个翻译服务。"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.pluginSettingsListRowPadding()
.pluginSettingsCardBackground(.host)
}
}
}
private var actions: some View {
HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) {
Button(localization.string("settings.action.restoreDefaults", defaultValue: "恢复默认")) {
profiles = [TranslatorProviderProfile.defaultProfile(localization: localization)]
selectedProfileID = profiles[0].id
apiKeys = [:]
message = nil
messageIsError = false
}
.buttonStyle(.bordered)
.controlSize(.small)
Spacer()
if let message {
Text(message)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(messageIsError ? Color.red : Color.secondary)
}
Button(localization.string("settings.action.save", defaultValue: "保存")) {
save()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private func providerRow(index: Int) -> some View {
let profile = profiles[index]
// The row toggle and move/delete buttons handle their own clicks. Only the name/model area
// selects the row, avoiding nested interactive controls inside one row-level Button.
return HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Toggle("", isOn: providerEnabledBinding(for: index))
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.small)
Button {
selectedProfileID = profile.id
} label: {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(profile.normalizedName.isEmpty
? localization.string("settings.providerRow.unnamed", defaultValue: "未命名服务")
: profile.normalizedName)
.font(PluginSettingsTheme.Typography.rowTitle)
Text(profile.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? localization.string("settings.providerRow.missingModel", defaultValue: "未设置模型")
: profile.model)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
if profile.isEnabled, let validationError = profile.validationError {
Text(validationError.errorDescription(localization: localization))
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(.orange)
.lineLimit(1)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
iconButton("chevron.up", help: localization.string("settings.providerRow.moveUpHelp", defaultValue: "上移")) {
moveProfile(from: index, offset: -1)
}
.disabled(index == 0)
iconButton("chevron.down", help: localization.string("settings.providerRow.moveDownHelp", defaultValue: "下移")) {
moveProfile(from: index, offset: 1)
}
.disabled(index == profiles.count - 1)
iconButton("trash", help: localization.string("settings.providerRow.deleteHelp", defaultValue: "删除")) {
deleteProfile(at: index)
}
.disabled(profiles.count == 1)
}
.pluginSettingsListRowPadding(interactive: true)
.background {
if selectedProfileID == profile.id {
RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous)
.fill(Color.accentColor.opacity(0.10))
.padding(.horizontal, 6)
.padding(.vertical, 4)
}
}
}
private func promptEditor(index: Int) -> some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(localization.string("settings.prompt.template.title", defaultValue: "模板"))
.font(PluginSettingsTheme.Typography.rowTitle)
Text(localization.string(
"settings.prompt.template.description",
defaultValue: "必须包含 {{text}}。可使用 {{source_language}} 和 {{target_language}}。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
TextEditor(text: binding(for: index, keyPath: \.promptTemplate))
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(8)
.frame(minHeight: 130, idealHeight: 160, maxHeight: 220)
.background(PluginSettingsTheme.Palette.nativeFieldBackground)
.clipShape(RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous))
}
.pluginSettingsListRowPadding(interactive: true)
}
private func sectionHeader(_ title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
}
private func fieldRow<Content: View>(
title: String,
description: String,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .firstTextBaseline, spacing: PluginSettingsTheme.Spacing.rowContentControl) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(title)
.font(PluginSettingsTheme.Typography.rowTitle)
Text(description)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
content()
}
.pluginSettingsListRowPadding(interactive: true)
}
private func editableFieldRow<Content: View>(
title: String,
description: String,
@ViewBuilder content: () -> Content
) -> some View {
fieldRow(title: title, description: description, content: content)
}
private func languagePicker(selection: Binding<TranslatorLanguage>) -> some View {
Picker("", selection: selection) {
ForEach(TranslatorLanguage.allCases) { language in
Text("\(language.flag) \(language.displayName(localization: localization))")
.tag(language)
}
}
.labelsHidden()
.frame(minWidth: 180, idealWidth: 200, maxWidth: 240)
}
private func iconButton(
_ systemName: String,
help: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 12, weight: .medium))
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.controlSize(.small)
.foregroundStyle(.secondary)
.help(help)
}
private var selectedProfileIndex: Int? {
guard let selectedProfileID,
let index = profiles.firstIndex(where: { $0.id == selectedProfileID })
else {
return profiles.indices.first
}
return index
}
private func binding<Value>(
for index: Int,
keyPath: WritableKeyPath<TranslatorProviderProfile, Value>
) -> Binding<Value> {
Binding(
get: { profiles[index][keyPath: keyPath] },
set: {
profiles[index][keyPath: keyPath] = $0
message = nil
}
)
}
private func apiKeyBinding(profileID: String) -> Binding<String> {
Binding(
get: { apiKeys[profileID] ?? "" },
set: {
apiKeys[profileID] = $0
message = nil
}
)
}
private func providerEnabledBinding(for index: Int) -> Binding<Bool> {
Binding(
get: { profiles[index].isEnabled },
set: { isEnabled in
guard profiles[index].isEnabled != isEnabled else {
return
}
let previousState = providerListState()
profiles[index].isEnabled = isEnabled
message = nil
messageIsError = false
persistProviderListChange(previousState: previousState)
}
)
}
/// Wraps a language binding and clears the "saved" message when the user makes unsaved edits.
private func messageClearing(_ binding: Binding<TranslatorLanguage>) -> Binding<TranslatorLanguage> {
Binding(
get: { binding.wrappedValue },
set: {
binding.wrappedValue = $0
message = nil
messageIsError = false
}
)
}
private func addProfile() {
let previousState = providerListState()
let profile = onMakeNewProfile(profiles)
profiles.append(profile)
selectedProfileID = profile.id
message = nil
messageIsError = false
persistProviderListChange(previousState: previousState)
}
private func deleteProfile(at index: Int) {
let previousState = providerListState()
let deletedID = profiles[index].id
profiles.remove(at: index)
apiKeys.removeValue(forKey: deletedID)
selectedProfileID = profiles.indices.contains(index) ? profiles[index].id : profiles.last?.id
message = nil
messageIsError = false
persistProviderListChange(previousState: previousState)
}
private func moveProfile(from index: Int, offset: Int) {
let target = index + offset
guard profiles.indices.contains(index), profiles.indices.contains(target) else {
return
}
let previousState = providerListState()
profiles.swapAt(index, target)
selectedProfileID = profiles[target].id
message = nil
messageIsError = false
persistProviderListChange(previousState: previousState)
}
private func save() {
let languagePair = TranslatorLanguagePair(first: firstLanguage, second: secondLanguage)
guard languagePair.first != languagePair.second else {
message = localization.string("settings.error.sameLanguages", defaultValue: "两种偏好语言不能相同。")
messageIsError = true
return
}
if let errorMessage = onSave(profiles, apiKeys, languagePair) {
message = errorMessage
messageIsError = true
} else {
savedLanguagePair = languagePair
message = localization.string("settings.message.saved", defaultValue: "已保存")
messageIsError = false
}
}
private struct ProviderListState {
var profiles: [TranslatorProviderProfile]
var apiKeys: [String: String]
var selectedProfileID: String?
}
private func providerListState() -> ProviderListState {
ProviderListState(
profiles: profiles,
apiKeys: apiKeys,
selectedProfileID: selectedProfileID
)
}
private func persistProviderListChange(previousState: ProviderListState) {
if let errorMessage = onSave(profiles, apiKeys, savedLanguagePair) {
profiles = previousState.profiles
apiKeys = previousState.apiKeys
selectedProfileID = previousState.selectedProfileID
message = errorMessage
messageIsError = true
return
}
message = localization.string("settings.message.saved", defaultValue: "已保存")
messageIsError = false
}
}