/* global Page, wx, require, getCurrentPages, clearTimeout, setTimeout, console */ const { getSettings, saveSettings, DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK, DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, normalizeVoiceRecordCategories, normalizeVoiceRecordDefaultCategory } = require("../../utils/storage"); const { DEFAULT_TERMINAL_RESUME_MINUTES, MIN_TERMINAL_RESUME_MINUTES, MAX_TERMINAL_RESUME_MINUTES } = require("../../utils/terminalSessionState"); const { DEFAULT_TTS_SPEAKABLE_MAX_CHARS, MIN_TTS_SPEAKABLE_MAX_CHARS, MAX_TTS_SPEAKABLE_MAX_CHARS, DEFAULT_TTS_SEGMENT_MAX_CHARS, MIN_TTS_SEGMENT_MAX_CHARS, MAX_TTS_SEGMENT_MAX_CHARS } = require("../../utils/ttsSettings"); const { buildThemeStyle, applyNavigationBarTheme, applyUiThemeSelection, applyShellThemeSelection, pickBtnColor, pickShellAccentColor, TERMINAL_SAFE_FONT_OPTIONS, normalizeTerminalFontFamily } = require("../../utils/themeStyle"); const { getRuntimeFingerprint } = require("../../utils/systemInfoCompat"); const { buildAiCodexSandboxOptions, buildAiCopilotPermissionOptions, buildAiProviderOptions, buildPageCopy, buildSettingsAuthTypeOptions, buildSettingsTabs, buildThemeModeOptions, buildThemePresetOptions, formatTemplate, getUiLanguageOptions, normalizeUiLanguage, t } = require("../../utils/i18n"); const { normalizeAiProvider, normalizeCodexSandboxMode, normalizeCopilotPermissionMode } = require("../../utils/aiLaunch"); const { emitLocaleChange } = require("../../utils/localeBus"); const SETTINGS_UI_STATE_KEY = "remoteconn.settings.uiState.v1"; const NUMBER_RULES = { reconnectLimit: { fallback: 3, min: 0, max: 10, integer: true }, backgroundSessionKeepAliveMinutes: { fallback: DEFAULT_TERMINAL_RESUME_MINUTES, min: MIN_TERMINAL_RESUME_MINUTES, max: MAX_TERMINAL_RESUME_MINUTES, integer: true }, defaultPort: { fallback: 22, min: 1, max: 65535, integer: true }, defaultTimeoutSeconds: { fallback: 20, min: 5, max: 600, integer: true }, defaultHeartbeatSeconds: { fallback: 15, min: 5, max: 600, integer: true }, shellFontSize: { fallback: 15, min: 12, max: 22, integer: true }, shellLineHeight: { fallback: 1.4, min: 1, max: 2, integer: false }, ttsSpeakableMaxChars: { fallback: DEFAULT_TTS_SPEAKABLE_MAX_CHARS, min: MIN_TTS_SPEAKABLE_MAX_CHARS, max: MAX_TTS_SPEAKABLE_MAX_CHARS, integer: true }, ttsSegmentMaxChars: { fallback: DEFAULT_TTS_SEGMENT_MAX_CHARS, min: MIN_TTS_SEGMENT_MAX_CHARS, max: MAX_TTS_SEGMENT_MAX_CHARS, integer: true }, shellBufferMaxEntries: { fallback: 5000, min: 100, max: 200000, integer: true }, shellBufferMaxBytes: { fallback: 4 * 1024 * 1024, min: 1024, max: 50 * 1024 * 1024, integer: true }, shellBufferSnapshotMaxLines: { fallback: DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, min: MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, max: MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, integer: true }, logRetentionDays: { fallback: 30, min: 1, max: 365, integer: true } }; const DEFAULT_AUTH_TYPE_OPTIONS = buildSettingsAuthTypeOptions("zh-Hans"); const DEFAULT_AI_PROVIDER_OPTIONS = buildAiProviderOptions("zh-Hans"); const DEFAULT_AI_CODEX_SANDBOX_OPTIONS = buildAiCodexSandboxOptions("zh-Hans"); const DEFAULT_AI_COPILOT_PERMISSION_OPTIONS = buildAiCopilotPermissionOptions("zh-Hans"); const THEME_MODE_OPTIONS = buildThemeModeOptions("zh-Hans"); const UI_LANGUAGE_OPTIONS = getUiLanguageOptions(); const UI_THEME_PRESET_OPTIONS = buildThemePresetOptions("zh-Hans"); const SHELL_THEME_PRESET_OPTIONS = UI_THEME_PRESET_OPTIONS; const MAX_SHELL_FONT_OPTIONS = 12; /** * 设置页启动时不能因为字体配置模块返回异常而整页崩掉。 * 这里保留一层兜底: * 1. 优先使用 themeStyle 导出的安全字体列表; * 2. 若导出值异常,则退回最小可用的等宽字体集合; * 3. 至少保证设置页能打开并继续保存其他配置。 */ const SETTINGS_FALLBACK_FONT_OPTIONS = [ { label: "等宽默认", value: 'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace' }, { label: "JetBrains Mono", value: '"JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace' } ]; const UNIVERSAL_SHELL_FONT_OPTIONS = Array.isArray(TERMINAL_SAFE_FONT_OPTIONS) && TERMINAL_SAFE_FONT_OPTIONS.length > 0 ? TERMINAL_SAFE_FONT_OPTIONS : SETTINGS_FALLBACK_FONT_OPTIONS; if (UNIVERSAL_SHELL_FONT_OPTIONS === SETTINGS_FALLBACK_FONT_OPTIONS) { console.warn("[settings.font_options] terminal safe font options unavailable, fallback applied"); } const SHELL_FONT_OPTION_LOOKUP = UNIVERSAL_SHELL_FONT_OPTIONS.reduce((acc, item) => { acc[item.label] = item; return acc; }, {}); const SHELL_FONT_VALUE_LABEL_LOOKUP = UNIVERSAL_SHELL_FONT_OPTIONS.reduce((acc, item) => { const key = String(item.value || "") .trim() .toLowerCase(); if (key) acc[key] = String(item.label || item.value); return acc; }, {}); const PLATFORM_FONT_PRIORITY = { ios: ["SF Mono", "Menlo", "等宽默认", "JetBrains Mono"], android: ["Roboto Mono", "JetBrains Mono", "等宽默认"], windows: ["Consolas", "JetBrains Mono", "等宽默认"], mac: ["SF Mono", "Menlo", "Monaco", "JetBrains Mono", "等宽默认"], linux: ["JetBrains Mono", "Consolas", "Roboto Mono", "等宽默认"], common: ["JetBrains Mono", "等宽默认", "Consolas"] }; const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; const BASE_COLOR_VALUES = [ "#192b4d", "#e6f0ff", "#5bd2ff", "#adb9cd", "#9ca9bf", "#3d86ff", "#3d405b", "#f4f1de", "#81b29a", "#e07a5f", "#073b4c", "#ffd166", "#06d6a0", "#ef476f", "#282c75", "#a8b868", "#7a71e4", "#4e4cc3", "#0f4c5c", "#fb8b24", "#e36414", "#cb4721", "#283d3b", "#edddd4", "#e9b5af", "#d99185", "#292281", "#f1f0cd", "#9d96ba", "#4a3ba6" ]; const DEFAULT_UI_ACCENT_COLOR = "#5bd2ff"; const DEFAULT_UI_BG_COLOR = "#192b4d"; const DEFAULT_UI_TEXT_COLOR = "#e6f0ff"; const DEFAULT_UI_BTN_COLOR = pickBtnColor(DEFAULT_UI_BG_COLOR, DEFAULT_UI_TEXT_COLOR); const DEFAULT_SHELL_BG_COLOR = "#192b4d"; const DEFAULT_SHELL_TEXT_COLOR = "#e6f0ff"; const DEFAULT_SHELL_ACCENT_COLOR = pickShellAccentColor(DEFAULT_SHELL_BG_COLOR, DEFAULT_SHELL_TEXT_COLOR); const COLOR_FIELD_KEYS = new Set([ "uiAccentColor", "uiBgColor", "uiTextColor", "uiBtnColor", "shellBgColor", "shellTextColor", "shellAccentColor" ]); function buildColorPaletteOptions() { const values = new Set(BASE_COLOR_VALUES.map((item) => item.toLowerCase())); UI_THEME_PRESET_OPTIONS.forEach((preset) => { ["dark", "light"].forEach((mode) => { const ui = applyUiThemeSelection({ uiThemePreset: preset.value, uiThemeMode: mode }); const shell = applyShellThemeSelection({ shellThemePreset: preset.value, shellThemeMode: mode }); [ui.uiAccentColor, ui.uiBgColor, ui.uiTextColor, ui.uiBtnColor].forEach((color) => { const value = String(color || "").toLowerCase(); if (HEX_COLOR_REGEX.test(value)) values.add(value); }); [shell.shellBgColor, shell.shellTextColor, shell.shellAccentColor].forEach((color) => { const value = String(color || "").toLowerCase(); if (HEX_COLOR_REGEX.test(value)) values.add(value); }); }); }); return [...values].map((value, index) => ({ label: `色板 ${String(index + 1).padStart(2, "0")}`, value })); } const COLOR_PALETTE_OPTIONS = buildColorPaletteOptions(); const COLOR_VALUE_SET = new Set(COLOR_PALETTE_OPTIONS.map((item) => item.value)); const VALID_TAB_IDS = new Set(["ui", "shell", "connection", "log"]); const MAX_VOICE_RECORD_CATEGORIES = 10; const CATEGORY_DRAG_TAP_LOCK_MS = 240; function resolveOptionIndex(options, value) { const raw = String(value == null ? "" : value); const lower = raw.toLowerCase(); const index = options.findIndex((item) => { const optionValue = String(item.value == null ? "" : item.value); return optionValue === raw || optionValue.toLowerCase() === lower; }); return index >= 0 ? index : 0; } function resolveSelection(options, rawValue) { if (!Array.isArray(options) || options.length === 0) { return { index: 0, option: null }; } const byIndex = Number(rawValue); if (Number.isInteger(byIndex) && byIndex >= 0 && byIndex < options.length) { return { index: byIndex, option: options[byIndex] }; } const rawText = String(rawValue == null ? "" : rawValue); const found = options.findIndex((item) => item.value === rawText || item.label === rawText); const index = found >= 0 ? found : 0; return { index, option: options[index] }; } function normalizeNumber(value, rule) { const parsed = Number(value); if (!Number.isFinite(parsed)) return rule.fallback; const normalized = rule.integer ? Math.round(parsed) : parsed; if (normalized < rule.min) return rule.min; if (normalized > rule.max) return rule.max; return normalized; } function normalizePaletteColor(value, fallback) { const raw = String(value || "") .trim() .toLowerCase(); if (COLOR_VALUE_SET.has(raw)) return raw; return fallback; } function resolveFontPlatformKey() { let systemInfo = {}; try { systemInfo = getRuntimeFingerprint(wx); } catch (error) { console.warn("[settings.fonts.system_info]", error); } const fingerprint = `${systemInfo.platform || ""} ${systemInfo.system || ""} ${systemInfo.brand || ""}`.toLowerCase(); if (/ios|iphone|ipad/.test(fingerprint)) return "ios"; if (/android/.test(fingerprint)) return "android"; if (/windows|win/.test(fingerprint)) return "windows"; if (/mac|darwin/.test(fingerprint)) return "mac"; if (/linux|devtools/.test(fingerprint)) return "linux"; return "common"; } function buildShellFontFamilyOptions(currentValue) { const options = []; const seen = new Set(); const appendOption = (option) => { if (!option || typeof option !== "object") return; const value = String(option.value || "").trim(); if (!value) return; const key = value.toLowerCase(); if (seen.has(key)) return; seen.add(key); options.push({ label: String(option.label || value), value }); }; const resolveCurrentLabel = (value) => { const raw = String(value || "").trim(); if (!raw) return ""; const matchedByValue = SHELL_FONT_VALUE_LABEL_LOOKUP[raw.toLowerCase()]; if (matchedByValue) return matchedByValue; const matchedByLabel = SHELL_FONT_OPTION_LOOKUP[raw]; if (matchedByLabel && matchedByLabel.label) return String(matchedByLabel.label); // 回退到字体族首项,避免显示“当前字体”这类无信息文案。 const firstFamily = raw .split(",")[0] .trim() .replace(/^["']|["']$/g, ""); return firstFamily || raw; }; const current = normalizeTerminalFontFamily(currentValue); if (current) { appendOption({ label: resolveCurrentLabel(current), value: current }); } const platformKey = resolveFontPlatformKey(); const priorityLabels = PLATFORM_FONT_PRIORITY[platformKey] || PLATFORM_FONT_PRIORITY.common; priorityLabels.forEach((label) => appendOption(SHELL_FONT_OPTION_LOOKUP[label])); UNIVERSAL_SHELL_FONT_OPTIONS.forEach((option) => appendOption(option)); return options.slice(0, MAX_SHELL_FONT_OPTIONS); } function getSavedSettingsUiState() { try { const state = wx.getStorageSync(SETTINGS_UI_STATE_KEY); return state && typeof state === "object" ? state : {}; } catch (error) { console.warn("[settings.ui_state.read]", error); return {}; } } function saveSettingsUiState(patch) { const nextPatch = patch && typeof patch === "object" ? patch : {}; try { const current = getSavedSettingsUiState(); wx.setStorageSync(SETTINGS_UI_STATE_KEY, { ...current, ...nextPatch }); } catch (error) { console.warn("[settings.ui_state.write]", error); } } function normalizeForm(input) { const form = { ...(input || {}) }; Object.keys(NUMBER_RULES).forEach((key) => { form[key] = normalizeNumber(form[key], NUMBER_RULES[key]); }); form.uiLanguage = normalizeUiLanguage(form.uiLanguage); form.uiThemeMode = form.uiThemeMode === "light" ? "light" : "dark"; form.uiThemePreset = String(form.uiThemePreset || "tide"); form.uiAccentColor = normalizePaletteColor(form.uiAccentColor, DEFAULT_UI_ACCENT_COLOR); form.uiBgColor = normalizePaletteColor(form.uiBgColor, DEFAULT_UI_BG_COLOR); form.uiTextColor = normalizePaletteColor(form.uiTextColor, DEFAULT_UI_TEXT_COLOR); form.uiBtnColor = normalizePaletteColor(form.uiBtnColor, DEFAULT_UI_BTN_COLOR); form.shellThemeMode = form.shellThemeMode === "light" ? "light" : "dark"; form.shellThemePreset = String(form.shellThemePreset || "tide"); form.shellBgColor = normalizePaletteColor(form.shellBgColor, DEFAULT_SHELL_BG_COLOR); form.shellTextColor = normalizePaletteColor(form.shellTextColor, DEFAULT_SHELL_TEXT_COLOR); form.shellAccentColor = normalizePaletteColor(form.shellAccentColor, DEFAULT_SHELL_ACCENT_COLOR); form.defaultAuthType = form.defaultAuthType === "key" ? "key" : "password"; form.shellActivationDebugOutline = form.shellActivationDebugOutline === undefined ? true : !!form.shellActivationDebugOutline; form.showVoiceInputButton = form.showVoiceInputButton === undefined ? true : !!form.showVoiceInputButton; form.unicode11 = !!form.unicode11; form.autoReconnect = !!form.autoReconnect; form.syncConfigEnabled = form.syncConfigEnabled === undefined ? true : !!form.syncConfigEnabled; form.aiDefaultProvider = normalizeAiProvider(form.aiDefaultProvider); form.aiCodexSandboxMode = normalizeCodexSandboxMode(form.aiCodexSandboxMode); form.aiCopilotPermissionMode = normalizeCopilotPermissionMode(form.aiCopilotPermissionMode); form.voiceRecordCategories = normalizeVoiceRecordCategories(form.voiceRecordCategories); form.voiceRecordDefaultCategory = normalizeVoiceRecordDefaultCategory( form.voiceRecordDefaultCategory, form.voiceRecordCategories ); return form; } function buildLocalizedFontOptions(language, currentValue) { const localizedFallback = t(language, "settings.options.fontFallback"); return buildShellFontFamilyOptions(currentValue).map((item) => { if (String(item.label || "").trim() === "等宽默认") { return { ...item, label: localizedFallback }; } return item; }); } function resolveSelectedVoiceRecordCategory(form, preferredValue) { const categories = normalizeVoiceRecordCategories(form && form.voiceRecordCategories); const preferred = String(preferredValue || "").trim(); if (preferred && categories.includes(preferred)) return preferred; return normalizeVoiceRecordDefaultCategory(form && form.voiceRecordDefaultCategory, categories); } function resolveTouchClientPoint(event) { const point = (event && event.touches && event.touches[0]) || (event && event.changedTouches && event.changedTouches[0]) || null; if (!point) return null; const x = Number(point.clientX); const y = Number(point.clientY); if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x, y }; } /** * 全局设置页(对齐 mini 配置实现方案): * 1. 四个 Tab:界面/终端/连接/日志; * 2. 运维配置保持不可见,仅通过 opsConfig 管理。 */ Page({ data: { canGoBack: false, activeTab: "ui", themeStyle: "", copy: buildPageCopy("zh-Hans", "settings"), terminalPreviewLine: "", tabs: buildSettingsTabs("zh-Hans"), form: getSettings(), activeColorPanelKey: "", saveStatusText: t("zh-Hans", "settings.saveStatus.synced"), newVoiceRecordCategory: "", selectedVoiceRecordCategory: "", voiceCategoryDragActive: false, voiceCategoryDragCategory: "", voiceRecordCategoryCards: [], defaultAuthTypeOptions: DEFAULT_AUTH_TYPE_OPTIONS, aiProviderOptions: DEFAULT_AI_PROVIDER_OPTIONS, aiCodexSandboxOptions: DEFAULT_AI_CODEX_SANDBOX_OPTIONS, aiCopilotPermissionOptions: DEFAULT_AI_COPILOT_PERMISSION_OPTIONS, uiLanguageOptions: UI_LANGUAGE_OPTIONS, colorPaletteOptions: COLOR_PALETTE_OPTIONS, shellFontFamilyOptions: UNIVERSAL_SHELL_FONT_OPTIONS.slice(0, MAX_SHELL_FONT_OPTIONS), uiThemeModeOptions: THEME_MODE_OPTIONS, uiThemePresetOptions: UI_THEME_PRESET_OPTIONS, shellThemeModeOptions: THEME_MODE_OPTIONS, shellThemePresetOptions: SHELL_THEME_PRESET_OPTIONS, defaultAuthTypeIndex: 0, uiLanguageIndex: 0, uiThemeModeIndex: 0, uiThemePresetIndex: 0, shellThemeModeIndex: 0, shellThemePresetIndex: 0, uiAccentColorIndex: 0, uiBgColorIndex: 0, uiTextColorIndex: 0, uiBtnColorIndex: 0, shellBgColorIndex: 0, shellTextColorIndex: 0, shellAccentColorIndex: 0, shellFontFamilyIndex: 0, aiDefaultProviderIndex: 0, aiCodexSandboxModeIndex: 0, aiCopilotPermissionModeIndex: 0 }, resolveThemeStyle(form) { applyNavigationBarTheme(form); return buildThemeStyle(form); }, getCurrentLanguage(formInput) { const source = formInput && typeof formInput === "object" ? formInput : this.data.form || {}; return normalizeUiLanguage(source.uiLanguage); }, resolveSaveStatusKey(text, language) { const normalizedLanguage = normalizeUiLanguage(language); const copy = buildPageCopy(normalizedLanguage, "settings"); const saveStatus = (copy && copy.saveStatus) || {}; if (text === saveStatus.saving) return "saving"; if (text === saveStatus.saved) return "saved"; return "synced"; }, buildSaveStatusText(statusKey, language) { const normalizedLanguage = normalizeUiLanguage(language); const safeKey = statusKey === "saving" || statusKey === "saved" ? statusKey : "synced"; return t(normalizedLanguage, `settings.saveStatus.${safeKey}`); }, applyLocale(formInput) { const form = normalizeForm(formInput || this.data.form); const language = this.getCurrentLanguage(form); const copy = buildPageCopy(language, "settings"); const shellFontFamilyOptions = buildLocalizedFontOptions(language, form.shellFontFamily); const uiLanguageOptions = getUiLanguageOptions(); const saveStatusKey = this.resolveSaveStatusKey(this.data.saveStatusText, language); const terminalPreviewLine = formatTemplate(copy?.hints?.terminalPreviewLine, { unicodeState: form.unicode11 ? copy?.hints?.voicePreviewUnicodeOn : copy?.hints?.voicePreviewUnicodeOff }); wx.setNavigationBarTitle({ title: copy.navTitle || t(language, "settings.navTitle") }); return { copy, terminalPreviewLine, tabs: buildSettingsTabs(language), defaultAuthTypeOptions: buildSettingsAuthTypeOptions(language), aiProviderOptions: buildAiProviderOptions(language), aiCodexSandboxOptions: buildAiCodexSandboxOptions(language), aiCopilotPermissionOptions: buildAiCopilotPermissionOptions(language), uiLanguageOptions, uiThemeModeOptions: buildThemeModeOptions(language), uiThemePresetOptions: buildThemePresetOptions(language), shellThemeModeOptions: buildThemeModeOptions(language), shellThemePresetOptions: buildThemePresetOptions(language), shellFontFamilyOptions, saveStatusText: this.buildSaveStatusText(saveStatusKey, language), ...this.buildOptionIndexState(form, shellFontFamilyOptions, { defaultAuthTypeOptions: buildSettingsAuthTypeOptions(language), aiProviderOptions: buildAiProviderOptions(language), aiCodexSandboxOptions: buildAiCodexSandboxOptions(language), aiCopilotPermissionOptions: buildAiCopilotPermissionOptions(language), uiLanguageOptions, uiThemeModeOptions: buildThemeModeOptions(language), uiThemePresetOptions: buildThemePresetOptions(language), shellThemeModeOptions: buildThemeModeOptions(language), shellThemePresetOptions: buildThemePresetOptions(language) }) }; }, onShow() { const pages = getCurrentPages(); this.clearAutoSaveTimer(); this.pendingAutoSaveForm = null; const form = normalizeForm(getSettings()); const uiState = getSavedSettingsUiState(); const activeTab = VALID_TAB_IDS.has(uiState.activeTab) ? uiState.activeTab : "ui"; const selectedVoiceRecordCategory = resolveSelectedVoiceRecordCategory(form); this.dragRuntime = null; this.dragTapLockUntil = 0; const localePatch = this.applyLocale(form); this.setData({ canGoBack: pages.length > 1, activeTab, form, selectedVoiceRecordCategory, voiceCategoryDragActive: false, voiceCategoryDragCategory: "", voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards(form, selectedVoiceRecordCategory), activeColorPanelKey: "", themeStyle: this.resolveThemeStyle(form), ...localePatch }); }, onHide() { this.flushAutoSave(this.pendingAutoSaveForm || this.data.form, { silent: true }); }, onUnload() { this.flushAutoSave(this.pendingAutoSaveForm || this.data.form, { silent: true }); this.clearAutoSaveTimer(); }, goBack() { if (!this.data.canGoBack) return; wx.navigateBack({ delta: 1 }); }, onTabTap(event) { const tab = event.currentTarget.dataset.tab; if (!VALID_TAB_IDS.has(tab)) return; this.flushAutoSave(this.pendingAutoSaveForm || this.data.form, { silent: true }); saveSettingsUiState({ activeTab: tab }); this.setData({ activeTab: tab, activeColorPanelKey: "" }); }, onInput(event) { const key = event.currentTarget.dataset.key; const raw = event.detail.value; // 输入阶段保留用户原始草稿,避免数字字段在键入过程中被立即“纠正”。 const form = { ...this.data.form, [key]: raw }; this.setData({ form, themeStyle: this.resolveThemeStyle(form), saveStatusText: this.buildSaveStatusText("saving", this.getCurrentLanguage(form)), terminalPreviewLine: this.applyLocale(form).terminalPreviewLine }); this.scheduleAutoSave(form); }, onSwitch(event) { const key = event.currentTarget.dataset.key; const form = { ...this.data.form, [key]: !!event.detail.value }; this.setData({ form, themeStyle: this.resolveThemeStyle(form), saveStatusText: this.buildSaveStatusText("saving", this.getCurrentLanguage(form)), terminalPreviewLine: this.applyLocale(form).terminalPreviewLine }); this.scheduleAutoSave(form); }, onSelectChange(event) { const key = event.currentTarget?.dataset?.key || event.target?.dataset?.key; const options = this.getOptionsByKey(key); if (!options || options.length === 0) return; const { option } = resolveSelection(options, event.detail && event.detail.value); if (!option) return; this.applySelectOption(key, option); }, applySelectOption(key, option) { if (!key || !option) return; const previousLanguage = this.getCurrentLanguage(this.data.form); let form = { ...this.data.form, [key]: option.value }; if (key === "uiLanguage") { form.uiLanguage = normalizeUiLanguage(option.value); } if (key === "uiThemeMode" || key === "uiThemePreset") { form = applyUiThemeSelection(form); } if (key === "shellThemeMode" || key === "shellThemePreset") { form = applyShellThemeSelection(form); } const localePatch = this.applyLocale(form); const patch = { form, themeStyle: this.resolveThemeStyle(form), activeColorPanelKey: "", ...localePatch, saveStatusText: this.buildSaveStatusText("saving", this.getCurrentLanguage(form)) }; this.setData(patch); const nextLanguage = this.getCurrentLanguage(form); if (key === "uiLanguage" && nextLanguage !== previousLanguage) { emitLocaleChange(nextLanguage); } if (COLOR_FIELD_KEYS.has(key)) { this.scheduleAutoSave(form); } else { this.flushAutoSave(form); } }, onPillSelect(event) { const key = event.currentTarget?.dataset?.key || event.target?.dataset?.key; const options = this.getOptionsByKey(key); if (!key || !options || options.length === 0) return; const index = Number(event.currentTarget?.dataset?.index); if (!Number.isInteger(index) || index < 0 || index >= options.length || !key) return; const option = options[index]; this.applySelectOption(key, option); }, onToggleColorPanel(event) { const key = event.currentTarget?.dataset?.key || event.target?.dataset?.key; if (!COLOR_FIELD_KEYS.has(key)) return; const activeColorPanelKey = this.data.activeColorPanelKey === key ? "" : key; this.setData({ activeColorPanelKey }); }, onPickPaletteColor(event) { const key = event.currentTarget?.dataset?.key || event.target?.dataset?.key; if (!COLOR_FIELD_KEYS.has(key)) return; const color = String(event.currentTarget?.dataset?.color || event.target?.dataset?.color || "") .trim() .toLowerCase(); if (!COLOR_VALUE_SET.has(color)) return; const form = { ...this.data.form, [key]: color }; this.setData({ form, activeColorPanelKey: "", themeStyle: this.resolveThemeStyle(form), ...this.applyLocale(form), saveStatusText: this.buildSaveStatusText("saving", this.getCurrentLanguage(form)) }); this.scheduleAutoSave(form); }, buildOptionIndexState(form, shellFontFamilyOptions, optionSets) { const options = optionSets && typeof optionSets === "object" ? optionSets : {}; const fontOptions = Array.isArray(shellFontFamilyOptions) && shellFontFamilyOptions.length > 0 ? shellFontFamilyOptions : this.data.shellFontFamilyOptions; const defaultAuthTypeOptions = options.defaultAuthTypeOptions || this.data.defaultAuthTypeOptions; const aiProviderOptions = options.aiProviderOptions || this.data.aiProviderOptions; const aiCodexSandboxOptions = options.aiCodexSandboxOptions || this.data.aiCodexSandboxOptions; const aiCopilotPermissionOptions = options.aiCopilotPermissionOptions || this.data.aiCopilotPermissionOptions; const uiLanguageOptions = options.uiLanguageOptions || this.data.uiLanguageOptions; const uiThemeModeOptions = options.uiThemeModeOptions || this.data.uiThemeModeOptions; const uiThemePresetOptions = options.uiThemePresetOptions || this.data.uiThemePresetOptions; const shellThemeModeOptions = options.shellThemeModeOptions || this.data.shellThemeModeOptions; const shellThemePresetOptions = options.shellThemePresetOptions || this.data.shellThemePresetOptions; return { defaultAuthTypeIndex: resolveOptionIndex(defaultAuthTypeOptions, form.defaultAuthType), aiDefaultProviderIndex: resolveOptionIndex(aiProviderOptions, form.aiDefaultProvider), aiCodexSandboxModeIndex: resolveOptionIndex(aiCodexSandboxOptions, form.aiCodexSandboxMode), aiCopilotPermissionModeIndex: resolveOptionIndex( aiCopilotPermissionOptions, form.aiCopilotPermissionMode ), uiLanguageIndex: resolveOptionIndex(uiLanguageOptions, form.uiLanguage), uiThemeModeIndex: resolveOptionIndex(uiThemeModeOptions, form.uiThemeMode), uiThemePresetIndex: resolveOptionIndex(uiThemePresetOptions, form.uiThemePreset), shellThemeModeIndex: resolveOptionIndex(shellThemeModeOptions, form.shellThemeMode), shellThemePresetIndex: resolveOptionIndex(shellThemePresetOptions, form.shellThemePreset), uiAccentColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.uiAccentColor), uiBgColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.uiBgColor), uiTextColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.uiTextColor), uiBtnColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.uiBtnColor), shellBgColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.shellBgColor), shellTextColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.shellTextColor), shellAccentColorIndex: resolveOptionIndex(COLOR_PALETTE_OPTIONS, form.shellAccentColor), shellFontFamilyIndex: resolveOptionIndex(fontOptions, form.shellFontFamily) }; }, getOptionsByKey(key) { if (key === "defaultAuthType") return this.data.defaultAuthTypeOptions; if (key === "aiDefaultProvider") return this.data.aiProviderOptions; if (key === "aiCodexSandboxMode") return this.data.aiCodexSandboxOptions; if (key === "aiCopilotPermissionMode") return this.data.aiCopilotPermissionOptions; if (key === "uiLanguage") return this.data.uiLanguageOptions; if (key === "uiThemeMode") return this.data.uiThemeModeOptions; if (key === "uiThemePreset") return this.data.uiThemePresetOptions; if (key === "shellThemeMode") return this.data.shellThemeModeOptions; if (key === "shellThemePreset") return this.data.shellThemePresetOptions; if (key === "shellFontFamily") return this.data.shellFontFamilyOptions; if (COLOR_FIELD_KEYS.has(key)) return COLOR_PALETTE_OPTIONS; return null; }, scheduleAutoSave(form) { this.pendingAutoSaveForm = normalizeForm(form || this.data.form); this.clearAutoSaveTimer(); this.autoSaveTimer = setTimeout(() => { this.flushAutoSave(this.pendingAutoSaveForm || form); }, 280); }, flushAutoSave(form, options) { const opts = options && typeof options === "object" ? options : {}; const normalized = normalizeForm(form || this.data.form); saveSettings(normalized); this.pendingAutoSaveForm = null; this.clearAutoSaveTimer(); if (opts.silent) return; this.setData({ saveStatusText: this.buildSaveStatusText("saved", this.getCurrentLanguage(normalized)) }); }, clearAutoSaveTimer() { if (!this.autoSaveTimer) return; clearTimeout(this.autoSaveTimer); this.autoSaveTimer = null; }, /** * 分类输入只更新本地临时态,真正持久化在点击“新增”时进行。 */ onVoiceRecordCategoryInput(event) { this.setData({ newVoiceRecordCategory: String(event.detail.value || "") }); }, /** * 选中分类卡片,后续默认/删除/排序都作用于当前选中项。 */ onSelectVoiceRecordCategory(event) { if (this.data.voiceCategoryDragActive || Date.now() < (this.dragTapLockUntil || 0)) return; const category = String(event.currentTarget.dataset.category || "").trim(); if (!category) return; this.setData({ selectedVoiceRecordCategory: category, voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards(this.data.form, category) }); }, /** * 新增分类:去空、去重、限制 10 项,并立即写回设置。 */ onAddVoiceRecordCategory() { const nextCategory = String(this.data.newVoiceRecordCategory || "").trim(); if (!nextCategory) { wx.showToast({ title: t(this.getCurrentLanguage(), "settings.toast.enterCategoryName"), icon: "none" }); return; } const categories = normalizeVoiceRecordCategories(this.data.form.voiceRecordCategories); if (categories.includes(nextCategory)) { wx.showToast({ title: t(this.getCurrentLanguage(), "settings.toast.categoryExists"), icon: "none" }); this.setData({ selectedVoiceRecordCategory: nextCategory }); return; } if (categories.length >= MAX_VOICE_RECORD_CATEGORIES) { wx.showToast({ title: t(this.getCurrentLanguage(), "settings.toast.maxCategories"), icon: "none" }); return; } const form = normalizeForm({ ...this.data.form, voiceRecordCategories: [...categories, nextCategory] }); this.commitVoiceRecordForm(form, { selectedVoiceRecordCategory: nextCategory, newVoiceRecordCategory: "" }); }, /** * 设为默认分类,后续语音记录默认落到该项。 */ applySelectedVoiceRecordCategoryAsDefault() { const selectedCategory = String(this.data.selectedVoiceRecordCategory || "").trim(); if (!selectedCategory) return; if (selectedCategory === this.data.form.voiceRecordDefaultCategory) return; const form = normalizeForm({ ...this.data.form, voiceRecordDefaultCategory: selectedCategory }); this.commitVoiceRecordForm(form, { selectedVoiceRecordCategory: selectedCategory }); }, /** * 删除所选分类。兜底项“未分类”不允许删,避免历史记录出现不可解释状态。 */ removeSelectedVoiceRecordCategory() { const selectedCategory = String(this.data.selectedVoiceRecordCategory || "").trim(); if (!selectedCategory) return; if (selectedCategory === DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK) { wx.showToast({ title: t(this.getCurrentLanguage(), "settings.toast.fallbackCannotDelete"), icon: "none" }); return; } const currentCategories = normalizeVoiceRecordCategories(this.data.form.voiceRecordCategories); if (currentCategories.length <= 1) { wx.showToast({ title: t(this.getCurrentLanguage(), "settings.toast.keepAtLeastOneCategory"), icon: "none" }); return; } const nextCategories = currentCategories.filter((item) => item !== selectedCategory); const preferredDefault = this.data.form.voiceRecordDefaultCategory === selectedCategory ? nextCategories[0] : this.data.form.voiceRecordDefaultCategory; const form = normalizeForm({ ...this.data.form, voiceRecordCategories: nextCategories, voiceRecordDefaultCategory: preferredDefault }); this.commitVoiceRecordForm(form, { selectedVoiceRecordCategory: resolveSelectedVoiceRecordCategory(form) }); }, /** * 统一提交分类相关设置,避免各操作重复拼装自动保存状态。 */ commitVoiceRecordForm(formInput, extraPatch) { const form = normalizeForm(formInput || this.data.form); const patch = extraPatch && typeof extraPatch === "object" ? extraPatch : {}; const selectedVoiceRecordCategory = patch.selectedVoiceRecordCategory !== undefined ? patch.selectedVoiceRecordCategory : resolveSelectedVoiceRecordCategory(form, this.data.selectedVoiceRecordCategory); this.setData({ form, themeStyle: this.resolveThemeStyle(form), saveStatusText: this.buildSaveStatusText("saving", this.getCurrentLanguage(form)), selectedVoiceRecordCategory, voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards(form, selectedVoiceRecordCategory), terminalPreviewLine: this.applyLocale(form).terminalPreviewLine, newVoiceRecordCategory: patch.newVoiceRecordCategory !== undefined ? patch.newVoiceRecordCategory : this.data.newVoiceRecordCategory }); this.flushAutoSave(form); }, buildVoiceRecordCategoryCards(formInput, selectedCategory, dragPatch) { const form = normalizeForm(formInput || this.data.form); const selected = resolveSelectedVoiceRecordCategory(form, selectedCategory); const patch = dragPatch && typeof dragPatch === "object" ? dragPatch : {}; const offsetMap = patch.offsetMap && typeof patch.offsetMap === "object" ? patch.offsetMap : {}; const draggingCategory = String(patch.draggingCategory || ""); return form.voiceRecordCategories.map((category) => { const dragState = offsetMap[category] || {}; const dragOffsetX = Number(dragState.x || 0); const dragOffsetY = Number(dragState.y || 0); const dragging = draggingCategory === category; return { category, isDefault: form.voiceRecordDefaultCategory === category, isSelected: selected === category, dragging, dragStyle: `transform: translate(${dragOffsetX}px, ${dragOffsetY}px); z-index: ${dragging ? 10 : 1};` }; }); }, onStartVoiceRecordCategoryDrag(event) { const category = String(event.currentTarget.dataset.category || "").trim(); if (!category || this.data.voiceCategoryDragActive) return; const categories = normalizeVoiceRecordCategories(this.data.form.voiceRecordCategories); if (categories.length <= 1) return; const fromIndex = categories.indexOf(category); if (fromIndex < 0) return; const query = wx.createSelectorQuery().in(this); query.selectAll(".voice-category-card").boundingClientRect((rects) => { if (!Array.isArray(rects) || rects.length !== categories.length) return; const startRect = rects[fromIndex]; if (!startRect) return; const point = resolveTouchClientPoint(event); const startX = point ? point.x : (Number(startRect.left) || 0) + (Number(startRect.width) || 0) / 2; const startY = point ? point.y : (Number(startRect.top) || 0) + (Number(startRect.height) || 0) / 2; this.dragRuntime = { category, fromIndex, toIndex: fromIndex, startX, startY, offsetX: 0, offsetY: 0, orderIds: categories.slice(), rects: rects.map((item) => ({ left: Number(item.left) || 0, top: Number(item.top) || 0, width: Number(item.width) || 0, height: Number(item.height) || 0, centerX: (Number(item.left) || 0) + (Number(item.width) || 0) / 2, centerY: (Number(item.top) || 0) + (Number(item.height) || 0) / 2 })) }; this.setData({ voiceCategoryDragActive: true, voiceCategoryDragCategory: category, selectedVoiceRecordCategory: category, voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards(this.data.form, category, { draggingCategory: category, offsetMap: { [category]: { x: 0, y: 0 } } }) }); }); query.exec(); }, buildVoiceCategoryDragOffsetMap() { if (!this.data.voiceCategoryDragActive || !this.dragRuntime) return {}; const runtime = this.dragRuntime; const reorderedIds = runtime.orderIds.slice(); const [draggingId] = reorderedIds.splice(runtime.fromIndex, 1); reorderedIds.splice(runtime.toIndex, 0, draggingId); const offsets = { [runtime.category]: { x: runtime.offsetX, y: runtime.offsetY } }; runtime.orderIds.forEach((category, index) => { if (category === runtime.category) return; const targetIndex = reorderedIds.indexOf(category); if (targetIndex < 0 || !runtime.rects[index] || !runtime.rects[targetIndex]) return; offsets[category] = { x: runtime.rects[targetIndex].left - runtime.rects[index].left, y: runtime.rects[targetIndex].top - runtime.rects[index].top }; }); return offsets; }, refreshVoiceCategoryDragVisual() { if (!this.data.voiceCategoryDragActive || !this.dragRuntime) return; this.setData({ voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards( this.data.form, this.data.selectedVoiceRecordCategory, { draggingCategory: this.dragRuntime.category, offsetMap: this.buildVoiceCategoryDragOffsetMap() } ) }); }, onVoiceRecordCategoryDragMove(event) { if (!this.data.voiceCategoryDragActive || !this.dragRuntime) return; const point = resolveTouchClientPoint(event); if (!point) return; const runtime = this.dragRuntime; runtime.offsetX = point.x - runtime.startX; runtime.offsetY = point.y - runtime.startY; const sourceRect = runtime.rects[runtime.fromIndex]; if (!sourceRect) return; const centerX = sourceRect.centerX + runtime.offsetX; const centerY = sourceRect.centerY + runtime.offsetY; let targetIndex = runtime.fromIndex; let minDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < runtime.rects.length; index += 1) { const rect = runtime.rects[index]; const distance = Math.hypot(centerX - rect.centerX, centerY - rect.centerY); if (distance < minDistance) { minDistance = distance; targetIndex = index; } } runtime.toIndex = targetIndex; this.refreshVoiceCategoryDragVisual(); }, onVoiceRecordCategoryDragEnd() { if (!this.data.voiceCategoryDragActive || !this.dragRuntime) return; const runtime = this.dragRuntime; const moved = runtime.toIndex !== runtime.fromIndex; const draggingCategory = runtime.category; this.dragRuntime = null; this.dragTapLockUntil = Date.now() + CATEGORY_DRAG_TAP_LOCK_MS; if (!moved || !draggingCategory) { this.setData({ voiceCategoryDragActive: false, voiceCategoryDragCategory: "", voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards( this.data.form, this.data.selectedVoiceRecordCategory ) }); return; } const nextCategories = normalizeVoiceRecordCategories(this.data.form.voiceRecordCategories); const fromIndex = nextCategories.indexOf(draggingCategory); if (fromIndex < 0) { this.setData({ voiceCategoryDragActive: false, voiceCategoryDragCategory: "", voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards( this.data.form, this.data.selectedVoiceRecordCategory ) }); return; } const [current] = nextCategories.splice(fromIndex, 1); if (!current) { this.setData({ voiceCategoryDragActive: false, voiceCategoryDragCategory: "", voiceRecordCategoryCards: this.buildVoiceRecordCategoryCards( this.data.form, this.data.selectedVoiceRecordCategory ) }); return; } nextCategories.splice(runtime.toIndex, 0, current); const form = normalizeForm({ ...this.data.form, voiceRecordCategories: nextCategories }); this.setData({ voiceCategoryDragActive: false, voiceCategoryDragCategory: "" }); this.commitVoiceRecordForm(form, { selectedVoiceRecordCategory: draggingCategory }); } });