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( 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( title: String, description: String, @ViewBuilder content: () -> Content ) -> some View { fieldRow(title: title, description: description, content: content) } private func languagePicker(selection: Binding) -> 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( for index: Int, keyPath: WritableKeyPath ) -> Binding { Binding( get: { profiles[index][keyPath: keyPath] }, set: { profiles[index][keyPath: keyPath] = $0 message = nil } ) } private func apiKeyBinding(profileID: String) -> Binding { Binding( get: { apiKeys[profileID] ?? "" }, set: { apiKeys[profileID] = $0 message = nil } ) } private func providerEnabledBinding(for index: Int) -> Binding { 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) -> Binding { 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 } }