From 5753899eb38bbf763b247a4b9129d4ae15032d32 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 26 Jun 2026 22:42:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=B8=BB=E9=A2=98=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=B3=BB=E7=BB=9F=E4=B8=8E=20LLM=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useThemeSkin 重构主题皮肤应用逻辑,支持企业 AI 风格主题切换 - settingsModelHelper 新增主题与模型表字段映射,useSettings 适配 - LlmSettingsPanel/SettingsView 面板重构,支持多模型行编辑与主题区块 - settings-view.css 适配主题样式,新增 settings-theme-section 测试,更新 settings-llm-section 测试 --- web/src/assets/styles/views/settings-view.css | 433 +++++++++++++++-- web/src/composables/useSettings.js | 47 +- web/src/composables/useThemeSkin.js | 165 +++---- web/src/utils/settingsModelHelper.js | 192 +++++++- web/src/views/LlmSettingsPanel.vue | 436 ++++++------------ web/src/views/SettingsView.vue | 49 +- web/src/views/scripts/LlmSettingsPanel.js | 303 ++++++------ web/tests/settings-llm-section.test.mjs | 18 +- web/tests/settings-theme-section.test.mjs | 73 +++ 9 files changed, 1099 insertions(+), 617 deletions(-) create mode 100644 web/tests/settings-theme-section.test.mjs diff --git a/web/src/assets/styles/views/settings-view.css b/web/src/assets/styles/views/settings-view.css index 89cd1ce..1af30a2 100644 --- a/web/src/assets/styles/views/settings-view.css +++ b/web/src/assets/styles/views/settings-view.css @@ -252,6 +252,331 @@ opacity: 0.6; } +.model-config-surface { + min-width: 0; +} + +.model-table-card > .model-table-wrap { + margin-top: 0; + padding: 0; + overflow: auto; +} + +.add-model-button, +.secondary-button, +.icon-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + color: #334155; + font-size: 12.5px; + font-weight: 700; + transition: all 0.2s ease; + cursor: pointer; +} + +.add-model-button { + min-height: 36px; + gap: 6px; + padding: 0 14px; + border-color: var(--theme-primary); + background: var(--theme-primary); + color: #ffffff; + box-shadow: 0 4px 12px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14); +} + +.add-model-button:hover, +.secondary-button:hover, +.icon-action-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.secondary-button { + min-height: 36px; + padding: 0 16px; +} + +.icon-action-button { + min-width: 52px; + width: auto; + height: 32px; + padding: 0 10px; + white-space: nowrap; +} + +.icon-action-button:hover:not(:disabled) { + border-color: var(--theme-primary); + color: var(--theme-primary-active); + background: var(--theme-primary-soft); +} + +.icon-action-button.danger:hover:not(:disabled) { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} + +.icon-action-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.model-config-table { + width: 100%; + min-width: 960px; + border-collapse: collapse; + table-layout: fixed; + background: #ffffff; +} + +.model-config-table th, +.model-config-table td { + padding: 13px 16px; + border-bottom: 1px solid #e2e8f0; + color: #334155; + font-size: 12.5px; + line-height: 1.45; + text-align: left; + vertical-align: middle; +} + +.model-config-table th { + background: #f8fafc; + color: #475569; + font-weight: 800; +} + +.model-config-table tbody tr:hover { + background: #f8fafc; +} + +.model-config-table th:nth-child(1), +.model-config-table td:nth-child(1) { + width: 132px; +} + +.model-config-table th:nth-child(2), +.model-config-table td:nth-child(2) { + width: 120px; +} + +.model-config-table th:nth-child(3), +.model-config-table td:nth-child(3) { + width: 180px; +} + +.model-config-table th:nth-child(5), +.model-config-table td:nth-child(5) { + width: 100px; +} + +.model-config-table th:nth-child(6), +.model-config-table td:nth-child(6) { + width: 180px; +} + +.model-action-col, +.model-config-table td:last-child { + width: 184px; +} + +.model-icon-text { + color: var(--theme-primary-active); + font-size: 13px; + font-weight: 900; + line-height: 1; +} + +.model-type-pill, +.secret-state, +.test-feedback-inline { + display: inline-flex; + align-items: center; + max-width: 100%; + gap: 6px; + min-height: 26px; + padding: 0 9px; + border-radius: 4px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.model-type-pill { + background: var(--theme-primary-soft); + color: var(--theme-primary-active); +} + +.secret-state { + background: #f1f5f9; + color: #64748b; +} + +.secret-state.configured { + background: #ecfdf5; + color: #047857; +} + +.test-feedback-inline { + overflow: hidden; + text-overflow: ellipsis; +} + +.test-feedback-inline.is-idle { + background: #f1f5f9; + color: #64748b; +} + +.test-feedback-inline.is-testing { + background: #eff6ff; + color: #2563eb; +} + +.test-feedback-inline.is-success { + background: #ecfdf5; + color: #047857; +} + +.test-feedback-inline.is-error { + background: #fef2f2; + color: #dc2626; +} + +.model-provider-name, +.model-id-text, +.model-url-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-provider-name { + color: #0f172a; + font-weight: 700; +} + +.model-id-text { + padding: 2px 0; + color: #0f172a; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + background: transparent; +} + +.model-url-text { + color: #64748b; +} + +.model-row-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.model-dialog-overlay { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.36); +} + +.model-dialog { + width: min(720px, 100%); + max-height: min(720px, calc(100vh - 48px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18); +} + +.model-dialog-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; +} + +.model-dialog-head h4 { + margin: 0; + color: #0f172a; + font-size: 17px; + font-weight: 800; +} + +.model-dialog-head p { + margin: 4px 0 0; + color: #64748b; + font-size: 12.5px; +} + +.model-dialog-form { + min-height: 0; + overflow: auto; + padding: 22px; +} + +.model-type-segment { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.model-type-segment button { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + color: #475569; + font-size: 12.5px; + font-weight: 800; + cursor: pointer; + transition: all 0.2s ease; +} + +.model-type-segment button.active { + border-color: var(--theme-primary); + background: var(--theme-primary-soft); + color: var(--theme-primary-active); +} + +.model-type-segment button:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.model-dialog-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 16px 22px; + border-top: 1px solid #e2e8f0; + background: #ffffff; +} + +.save-button.compact { + min-height: 36px; + padding: 0 16px; +} + .profile-grid { grid-template-columns: 96px repeat(2, minmax(0, 1fr)); } @@ -387,18 +712,18 @@ color: var(--theme-primary-active); } -.skin-option-grid { +.theme-option-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; } -.skin-option { - min-height: 104px; +.theme-option { + min-height: 148px; display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 14px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 16px; padding: 16px; border: 1px solid #d8dee8; border-radius: 4px; @@ -411,18 +736,19 @@ box-shadow 160ms var(--ease); } -.skin-option:hover, -.skin-option.active { +.theme-option:hover, +.theme-option.active { border-color: var(--primary); - background: var(--theme-primary-light-9); + background: #ffffff; box-shadow: 0 0 0 3px var(--theme-focus-ring); } -.skin-swatch { - width: 64px; - height: 38px; +.theme-style-preview { + grid-column: 1 / -1; + width: 100%; + height: 42px; display: grid; - grid-template-columns: 1.3fr 1fr 1fr 1fr; + grid-template-columns: 1.4fr 1fr 1fr 0.8fr; grid-template-rows: 1fr; border: 1px solid #d8dee8; border-radius: 4px; @@ -430,28 +756,48 @@ background: #ffffff; } -.skin-swatch i + i { +.theme-style-preview i + i { border-left: 1px solid rgba(255, 255, 255, 0.72); } -.skin-copy { +.theme-copy { display: grid; - gap: 4px; + gap: 8px; } -.skin-copy strong { +.theme-copy strong { color: #111827; - font-size: 14px; + font-size: 15px; font-weight: 700; } -.skin-copy small { +.theme-copy small { color: #64748b; font-size: 12px; line-height: 1.45; } -.skin-current { +.theme-keywords { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.theme-keywords em { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; + color: #64748b; + font-size: 11px; + font-style: normal; + font-weight: 700; +} + +.theme-current { min-height: 24px; display: inline-flex; align-items: center; @@ -464,7 +810,7 @@ font-weight: 700; } -.skin-preview-panel { +.theme-preview-panel { display: flex; align-items: center; justify-content: space-between; @@ -472,33 +818,56 @@ padding: 16px; border: 1px solid #d8dee8; border-radius: 4px; - background: linear-gradient(180deg, #ffffff 0%, var(--theme-primary-light-9) 100%); + background: #ffffff; } -.skin-preview-panel div { +.theme-preview-panel div { display: grid; gap: 4px; } -.skin-preview-panel strong { +.theme-preview-panel strong { color: #111827; font-size: 14px; } -.skin-preview-panel span { +.theme-preview-panel span { color: #64748b; font-size: 12px; } -.skin-preview-action { - min-height: 34px; - padding: 0 14px; - border: 1px solid var(--primary); +.theme-preview-surface { + width: min(220px, 36%); + min-width: 160px; + display: grid; + grid-template-columns: 1fr 44px; + gap: 8px; + padding: 12px; + border: 1px solid #e2e8f0; border-radius: 4px; + background: #f8fafc; +} + +.theme-preview-surface span, +.theme-preview-surface i, +.theme-preview-surface b { + display: block; + min-height: 10px; + border-radius: 3px; +} + +.theme-preview-surface span { + grid-column: 1 / -1; + background: var(--theme-primary-soft); +} + +.theme-preview-surface i { + background: #ffffff; + border: 1px solid #e2e8f0; +} + +.theme-preview-surface b { background: var(--theme-gradient-primary); - color: #fff; - font-size: 13px; - font-weight: 700; } .secret-bound-state { diff --git a/web/src/composables/useSettings.js b/web/src/composables/useSettings.js index 8d23769..166003b 100644 --- a/web/src/composables/useSettings.js +++ b/web/src/composables/useSettings.js @@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useSystemState } from './useSystemState.js' -import { useThemeSkin } from './useThemeSkin.js' +import { normalizeThemeMode, useThemeSkin } from './useThemeSkin.js' import { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js' import { useToast } from './useToast.js' import { @@ -22,6 +22,7 @@ import { maskConfiguredModelSecrets, maskConfiguredRenderSecret, mergeState, + normalizeLlmModelRows, normalizeValue, persistSettings, readStoredSettings @@ -61,6 +62,8 @@ export function useSettings() { const cacheClearMessage = ref('') const cacheClearFailed = ref(false) + pageState.value.appearanceForm.themeSkin = setThemeSkin(pageState.value.appearanceForm.themeSkin) + const sections = SECTION_DEFINITIONS const logLevels = LOG_LEVELS const providerOptions = PROVIDER_OPTIONS @@ -108,6 +111,13 @@ export function useSettings() { nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey + const modelApiKeysBySlot = new Map( + normalizeLlmModelRows(currentState.llmForm.models).map((row) => [row.slot, row.apiKey]) + ) + nextState.llmForm.models = normalizeLlmModelRows(nextState.llmForm.models).map((row) => ({ + ...row, + apiKey: modelApiKeysBySlot.get(row.slot) ?? row.apiKey + })) } if (preserveAdminPasswords) { @@ -123,13 +133,16 @@ export function useSettings() { nextState.mailForm.password = currentState.mailForm.password } + const normalizedThemeMode = normalizeThemeMode(nextState.appearanceForm?.themeSkin) + nextState.appearanceForm = { + ...nextState.appearanceForm, + themeSkin: normalizedThemeMode + } + pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState)) persistSettings(pageState.value) updateBrandPreviewFromState(pageState.value) - - if (nextState.appearanceForm?.themeSkin) { - setThemeSkin(nextState.appearanceForm.themeSkin) - } + setThemeSkin(normalizedThemeMode) } async function loadSettingsSnapshot() { @@ -358,12 +371,12 @@ export function useSettings() { } function selectThemeSkin(skinId) { - setThemeSkin(skinId) - pageState.value.appearanceForm.themeSkin = skinId + pageState.value.appearanceForm.themeSkin = setThemeSkin(skinId) } async function saveAppearanceSection() { - await persistRemoteSettings('界面皮肤已保存并应用到企业配置。', { + pageState.value.appearanceForm.themeSkin = normalizeThemeMode(pageState.value.appearanceForm.themeSkin) + await persistRemoteSettings('主题设置已保存并应用到企业配置。', { preserveModelApiKeys: true, preserveAdminPasswords: true, preserveRenderSecret: true, @@ -373,16 +386,16 @@ export function useSettings() { async function saveLlmSection() { const llmForm = pageState.value.llmForm - const modelConfigs = [ - ['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint], - ['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint], - ['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint], - ['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint] - ] + const modelRows = normalizeLlmModelRows(llmForm.models) - for (const [label, provider, model, endpoint] of modelConfigs) { - if (!isModelConfigReady(provider, model, endpoint)) { - toast(`请完整填写${label}的供应商、模型名称和接口地址。`) + if (modelRows.length === 0) { + toast('请至少添加一个模型配置。') + return + } + + for (const row of modelRows) { + if (!isModelConfigReady(row.provider, row.modelId, row.url)) { + toast('请完整填写每个模型的供应商、model_id 和接口地址。') return } } diff --git a/web/src/composables/useThemeSkin.js b/web/src/composables/useThemeSkin.js index 6d39ca7..5efd404 100644 --- a/web/src/composables/useThemeSkin.js +++ b/web/src/composables/useThemeSkin.js @@ -1,7 +1,7 @@ import { computed, ref } from 'vue' const THEME_SKIN_STORAGE_KEY = 'x-financial-theme-skin' -const DEFAULT_THEME_SKIN_ID = 'sky' +const DEFAULT_THEME_SKIN_ID = 'enterprise' const DEFAULT_SEMANTIC_COLORS = { success: '#2f855a', @@ -28,112 +28,45 @@ const DEFAULT_SEMANTIC_COLORS = { export const THEME_SKIN_OPTIONS = [ { - id: 'sky', - label: '浅蓝企业', - desc: '默认皮肤,降低蓝色饱和度,适合财务 SaaS 和审批后台。', - primary: '#3a7ca5', - primaryHover: '#2f6d95', - primaryActive: '#255b7d', - primarySoft: '#eaf4fa', - primarySoftStrong: '#d4e8f3', - secondary: '#4f6f9f', - chartBlue: '#4f6f9f', - chartPurple: '#6e7fa6', - chartAmber: '#b58b4c' + id: 'vivid', + label: '动感活泼', + desc: '保留当前 AI 助手的明快节奏,适合演示、培训和轻量工作台。', + keywords: ['明快', '渐变', '助手感'], + primary: '#2f7cff', + primaryHover: '#2563eb', + primaryActive: '#1d4ed8', + primarySoft: '#eef6ff', + primarySoftStrong: '#dbeafe', + secondary: '#7c5cff', + chartBlue: '#2f7cff', + chartPurple: '#7c5cff', + chartAmber: '#f59e0b' }, { - id: 'blue', - label: '湖蓝灰', - desc: '偏灰的湖蓝色,弱化科技感,更适合高密度运营页面。', - primary: '#477c9e', - primaryHover: '#3a6a89', - primaryActive: '#305873', - primarySoft: '#edf5f8', - primarySoftStrong: '#d8e8ef', - secondary: '#5d7288', - chartBlue: '#477c9e', - chartPurple: '#77799c', - chartAmber: '#b28a54' - }, - { - id: 'navy', - label: '稳健蓝', - desc: '偏金融和管理驾驶舱的稳重蓝,适合长时间办公查看。', - primary: '#4b6f95', - primaryHover: '#405f80', - primaryActive: '#354e69', - primarySoft: '#eef3f8', - primarySoftStrong: '#dbe6f0', - secondary: '#6b7280', - chartBlue: '#4b6f95', - chartPurple: '#69769d', - chartAmber: '#aa8a55' - }, - { - id: 'teal', - label: '雾青', - desc: '保留绿色倾向但降低鲜艳度,比旧绿色更克制。', - primary: '#3f827c', - primaryHover: '#36706b', - primaryActive: '#2d5c58', - primarySoft: '#eef8f6', - primarySoftStrong: '#d8ebe8', - secondary: '#4f6f9f', - chartBlue: '#4f7f9f', - chartPurple: '#708099', - chartAmber: '#b18a53' - }, - { - id: 'legacy-green', - label: '经典绿', - desc: '保留旧版系统绿色,适合继续沿用原有品牌记忆。', - primary: '#10b981', - primaryHover: '#059669', - primaryActive: '#047857', - primarySoft: '#ecfdf5', - primarySoftStrong: '#d1fae5', - secondary: '#2563eb', - chartBlue: '#2563eb', - chartPurple: '#6d6a9f', - chartAmber: '#b88a44' - }, - { - id: 'sage', - label: '鼠尾草绿', - desc: '低饱和灰绿色,比经典绿更安静,适合企业内控场景。', - primary: '#5f8d72', - primaryHover: '#517b62', - primaryActive: '#436653', - primarySoft: '#f0f7f2', - primarySoftStrong: '#dcebe0', - secondary: '#4f6f9f', - chartBlue: '#4f748f', - chartPurple: '#7a7898', - chartAmber: '#a98753' - }, - { - id: 'slate', - label: '石板灰蓝', - desc: '弱主色方案,适合审计、规则和报表密集页面。', - primary: '#64748b', - primaryHover: '#526174', - primaryActive: '#3f4a5a', + id: 'enterprise', + label: '企业沉稳', + desc: '低饱和、轻描边、少渲染,适合正式生产环境和企业级财务 SaaS。', + keywords: ['克制', '结构化', '低噪声'], + primary: '#475569', + primaryHover: '#3f4a5a', + primaryActive: '#334155', primarySoft: '#f1f5f9', primarySoftStrong: '#e2e8f0', - secondary: '#3a7ca5', + secondary: '#64748b', chartBlue: '#5d7590', - chartPurple: '#77748f', - chartAmber: '#a88955' + chartPurple: '#6b7280', + chartAmber: '#9a7a45' }, { - id: 'soft-violet', - label: '灰紫蓝', - desc: '保留一点智能系统气质,但用灰度压低 AI 感和饱和度。', - primary: '#6d6a9f', - primaryHover: '#5f5b8c', - primaryActive: '#504c78', - primarySoft: '#f2f1f8', - primarySoftStrong: '#e2e0ef', + id: 'intelligent', + label: '专业智能', + desc: '保留少量智能识别感,同时控制饱和度,适合稳定办公和 AI 辅助并重的团队。', + keywords: ['智能', '专业', '轻点缀'], + primary: '#5f6f9f', + primaryHover: '#53618b', + primaryActive: '#465275', + primarySoft: '#f3f4fb', + primarySoftStrong: '#e2e5f4', secondary: '#477c9e', chartBlue: '#4f7495', chartPurple: '#6d6a9f', @@ -142,9 +75,36 @@ export const THEME_SKIN_OPTIONS = [ ] const activeThemeSkinId = ref(DEFAULT_THEME_SKIN_ID) +const THEME_MODE_IDS = new Set(THEME_SKIN_OPTIONS.map((skin) => skin.id)) +const LEGACY_THEME_MODE_MAP = { + sky: 'vivid', + blue: 'vivid', + emerald: 'vivid', + teal: 'vivid', + 'legacy-green': 'vivid', + navy: 'enterprise', + slate: 'enterprise', + sage: 'enterprise', + gray: 'enterprise', + grey: 'enterprise', + purple: 'intelligent', + violet: 'intelligent', + 'soft-violet': 'intelligent' +} + +export function normalizeThemeMode(value) { + const normalized = String(value ?? '').trim() + + if (THEME_MODE_IDS.has(normalized)) { + return normalized + } + + return LEGACY_THEME_MODE_MAP[normalized] || DEFAULT_THEME_SKIN_ID +} function findThemeSkin(id) { - return THEME_SKIN_OPTIONS.find((skin) => skin.id === id) || THEME_SKIN_OPTIONS[0] + const themeMode = normalizeThemeMode(id) + return THEME_SKIN_OPTIONS.find((skin) => skin.id === themeMode) || THEME_SKIN_OPTIONS[0] } function hexToRgb(hex) { @@ -185,6 +145,7 @@ function applyThemeSkin(skin) { const infoRgb = hexToRgb(DEFAULT_SEMANTIC_COLORS.info) root.dataset.themeSkin = skin.id + root.dataset.themeMode = skin.id setVariables(root, { '--primary': skin.primary, @@ -270,6 +231,8 @@ export function setThemeSkin(id) { if (typeof window !== 'undefined') { window.localStorage.setItem(THEME_SKIN_STORAGE_KEY, skin.id) } + + return skin.id } export function useThemeSkin() { diff --git a/web/src/utils/settingsModelHelper.js b/web/src/utils/settingsModelHelper.js index 4716d4a..b7e748b 100644 --- a/web/src/utils/settingsModelHelper.js +++ b/web/src/utils/settingsModelHelper.js @@ -21,11 +21,11 @@ export const SECTION_DEFINITIONS = [ }, { id: 'appearance', - label: '界面皮肤', - title: '界面皮肤与主色', - desc: '整体主色与控件观感', - longDesc: '设置当前浏览器的界面主色。默认使用浅蓝企业主题,后续可扩展为企业级统一下发。', - actionLabel: '保存皮肤设置' + label: '主题设置', + title: '主题风格与界面体验', + desc: '动感、沉稳与智能风格', + longDesc: '选择当前系统的整体体验风格。主题会联动全局主色、控件状态和 AI 模式的对话呈现。', + actionLabel: '保存主题设置' }, { id: 'admin', @@ -173,11 +173,58 @@ export const MODEL_TEST_CONFIGS = { } export const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS) +export const MODEL_TYPE_OPTIONS = [ + { label: '大语言模型', value: 'llm', capability: 'chat' }, + { label: 'Embedding', value: 'embedding', capability: 'embedding' }, + { label: 'Rerank', value: 'rerank', capability: 'reranker' } +] +export const MODEL_TYPE_LABELS = Object.fromEntries(MODEL_TYPE_OPTIONS.map((option) => [option.value, option.label])) +export const FIXED_MODEL_ROW_DEFINITIONS = [ + { + slot: 'main', + type: 'llm', + providerKey: 'mainProvider', + modelKey: 'mainModel', + endpointKey: 'mainEndpoint', + apiKeyKey: 'mainApiKey', + configuredKey: 'mainApiKeyConfigured' + }, + { + slot: 'backup', + type: 'llm', + providerKey: 'backupProvider', + modelKey: 'backupModel', + endpointKey: 'backupEndpoint', + apiKeyKey: 'backupApiKey', + configuredKey: 'backupApiKeyConfigured' + }, + { + slot: 'embedding', + type: 'embedding', + providerKey: 'embeddingProvider', + modelKey: 'embeddingModel', + endpointKey: 'embeddingEndpoint', + apiKeyKey: 'embeddingApiKey', + configuredKey: 'embeddingApiKeyConfigured' + }, + { + slot: 'reranker', + type: 'rerank', + providerKey: 'rerankerProvider', + modelKey: 'rerankerModel', + endpointKey: 'rerankerEndpoint', + apiKeyKey: 'rerankerApiKey', + configuredKey: 'rerankerApiKeyConfigured' + } +] export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({ value: index + 1, label: `${index + 1} 天` })) +const FIXED_MODEL_ROW_SLOTS = new Set(FIXED_MODEL_ROW_DEFINITIONS.map((definition) => definition.slot)) +const MODEL_TYPE_VALUES = new Set(MODEL_TYPE_OPTIONS.map((option) => option.value)) + export function normalizeValue(value) { return String(value ?? '').trim() } @@ -204,6 +251,68 @@ export function getRerankerEndpoint(provider) { return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider) } +function normalizeModelType(type, fallback = 'llm') { + const normalized = normalizeValue(type) + return MODEL_TYPE_VALUES.has(normalized) ? normalized : fallback +} + +export function normalizeLlmModelRows(rows) { + if (!Array.isArray(rows)) { + return [] + } + + return rows + .map((row) => ({ + slot: normalizeValue(row?.slot), + provider: normalizeProviderValue(row?.provider, CUSTOM_OPENAI_PROVIDER), + url: normalizeValue(row?.url ?? row?.endpoint), + apiKey: normalizeValue(row?.apiKey), + apiKeyConfigured: Boolean(row?.apiKeyConfigured), + modelId: normalizeValue(row?.modelId ?? row?.model), + type: normalizeModelType(row?.type) + })) + .filter((row) => row.slot) +} + +function buildFixedModelRow(llmForm, definition) { + return { + slot: definition.slot, + provider: normalizeProviderValue(llmForm?.[definition.providerKey], 'Codex'), + url: normalizeValue(llmForm?.[definition.endpointKey]), + apiKey: normalizeValue(llmForm?.[definition.apiKeyKey]), + apiKeyConfigured: Boolean(llmForm?.[definition.configuredKey]), + modelId: normalizeValue(llmForm?.[definition.modelKey]), + type: definition.type + } +} + +export function buildLlmModelRows(llmForm) { + const fixedRows = FIXED_MODEL_ROW_DEFINITIONS.map((definition) => buildFixedModelRow(llmForm, definition)) + const customRows = normalizeLlmModelRows(llmForm?.models).filter((row) => !FIXED_MODEL_ROW_SLOTS.has(row.slot)) + + return normalizeLlmModelRows([...fixedRows, ...customRows]) +} + +export function syncLegacyModelFieldsFromRows(llmForm) { + const rows = normalizeLlmModelRows(llmForm?.models) + const nextForm = { ...llmForm, models: rows } + + for (const definition of FIXED_MODEL_ROW_DEFINITIONS) { + const row = rows.find((item) => item.slot === definition.slot) + if (!row) { + continue + } + + nextForm[definition.providerKey] = row.provider + nextForm[definition.modelKey] = row.modelId + nextForm[definition.endpointKey] = row.url + nextForm[definition.apiKeyKey] = row.apiKey + nextForm[definition.configuredKey] = row.apiKeyConfigured + } + + return nextForm +} + export function buildDefaultState(companyProfile, currentUser) { const companyName = normalizeValue(companyProfile?.name) || 'X-Financial' const companyCode = normalizeValue(companyProfile?.code) || 'XF-001' @@ -223,7 +332,7 @@ export function buildDefaultState(companyProfile, currentUser) { copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.` }, appearanceForm: { - themeSkin: 'sky' + themeSkin: 'enterprise' }, adminForm: { adminAccount, @@ -260,7 +369,29 @@ export function buildDefaultState(companyProfile, currentUser) { rerankerModel: 'gte-rerank-v2', rerankerEndpoint: getRerankerEndpoint('Ali'), rerankerApiKey: '', - rerankerApiKeyConfigured: false + rerankerApiKeyConfigured: false, + models: buildLlmModelRows({ + mainProvider: 'Codex', + mainModel: 'codex-mini-latest', + mainEndpoint: getProviderEndpoint('Codex'), + mainApiKey: '', + mainApiKeyConfigured: false, + backupProvider: 'GLM', + backupModel: 'glm-5.1', + backupEndpoint: getProviderEndpoint('GLM'), + backupApiKey: '', + backupApiKeyConfigured: false, + embeddingProvider: 'GLM', + embeddingModel: 'Embedding-3', + embeddingEndpoint: getProviderEndpoint('GLM'), + embeddingApiKey: '', + embeddingApiKeyConfigured: false, + rerankerProvider: 'Ali', + rerankerModel: 'gte-rerank-v2', + rerankerEndpoint: getRerankerEndpoint('Ali'), + rerankerApiKey: '', + rerankerApiKeyConfigured: false + }) }, renderForm: { enabled: false, @@ -326,6 +457,7 @@ export function mergeState(baseState, overrideState) { mergedLlmForm.rerankerProvider, baseState.llmForm.rerankerProvider ) + mergedLlmForm.models = buildLlmModelRows(mergedLlmForm) return { companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) }, @@ -355,11 +487,15 @@ export function sanitizeForStorage(state) { sessionForm: { ...state.sessionForm }, hermesForm: mergeHermesEmployeeForm(state.hermesForm), llmForm: { - ...state.llmForm, + ...syncLegacyModelFieldsFromRows(state.llmForm), mainApiKey: '', backupApiKey: '', embeddingApiKey: '', - rerankerApiKey: '' + rerankerApiKey: '', + models: normalizeLlmModelRows(state.llmForm.models).map((row) => ({ + ...row, + apiKey: '' + })) }, renderForm: { ...state.renderForm, @@ -390,11 +526,21 @@ export function maskConfiguredModelSecrets(state) { } } + state.llmForm.models = normalizeLlmModelRows(state.llmForm.models).map((row) => { + if (row.apiKeyConfigured && !normalizeValue(row.apiKey)) { + return { ...row, apiKey: MODEL_SECRET_MASK } + } + return row + }) + return state } export function buildLlmPayload(llmForm) { - const payload = { ...llmForm } + const payload = syncLegacyModelFieldsFromRows({ + ...llmForm, + models: normalizeLlmModelRows(llmForm.models) + }) for (const config of MODEL_API_KEY_CONFIGS) { if (isModelSecretMask(payload[config.apiKeyKey])) { @@ -402,6 +548,11 @@ export function buildLlmPayload(llmForm) { } } + payload.models = normalizeLlmModelRows(payload.models).map((row) => ({ + ...row, + apiKey: isModelSecretMask(row.apiKey) ? '' : row.apiKey + })) + return payload } @@ -457,20 +608,13 @@ export function computeSectionStatus(state) { Number(state.sessionForm.conversationRetentionDays) <= 10 ), hermes: isHermesEmployeeSettingsReady(state.hermesForm), - llm: Boolean( - isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) && - isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) && - isModelConfigReady( - state.llmForm.embeddingProvider, - state.llmForm.embeddingModel, - state.llmForm.embeddingEndpoint - ) && - isModelConfigReady( - state.llmForm.rerankerProvider, - state.llmForm.rerankerModel, - state.llmForm.rerankerEndpoint - ) - ), + llm: (() => { + const rows = normalizeLlmModelRows(state.llmForm.models) + return Boolean( + rows.length > 0 && + rows.every((row) => isModelConfigReady(row.provider, row.modelId, row.url)) + ) + })(), rendering: Boolean( !state.renderForm.enabled || (normalizeValue(state.renderForm.publicUrl) && diff --git a/web/src/views/LlmSettingsPanel.vue b/web/src/views/LlmSettingsPanel.vue index 58261fe..3423363 100644 --- a/web/src/views/LlmSettingsPanel.vue +++ b/web/src/views/LlmSettingsPanel.vue @@ -1,316 +1,178 @@ diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 2878f61..95cce5c 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -109,44 +109,51 @@
- +
-

界面皮肤与企业主色

-

只调整整体主色、焦点态、按钮和 Element Plus 控件颜色,不改变业务布局。

+

主题风格与界面体验

+

选择系统整体体验风格,并联动 AI 模式的对话、图标、卡片和提示样式。

-
+
-
+
{{ activeThemeSkin.label }} - 当前主色会同步到全局按钮、焦点环、下拉浮层和表单控件。 + 当前主题会同步到全局按钮、焦点环、表单控件和 AI 对话界面。 +
+ -
diff --git a/web/src/views/scripts/LlmSettingsPanel.js b/web/src/views/scripts/LlmSettingsPanel.js index d550f60..3d29507 100644 --- a/web/src/views/scripts/LlmSettingsPanel.js +++ b/web/src/views/scripts/LlmSettingsPanel.js @@ -1,103 +1,55 @@ -import { ref } from 'vue' +import { computed, ref } from 'vue' import { testModelConnectivity } from '../../services/settings.js' import { useToast } from '../../composables/useToast.js' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' +import { + CUSTOM_OPENAI_PROVIDER, + MODEL_SECRET_MASK, + MODEL_TYPE_LABELS, + MODEL_TYPE_OPTIONS, + getProviderEndpoint, + getRerankerEndpoint, + isModelSecretMask, + normalizeLlmModelRows, + normalizeProviderValue, + normalizeValue +} from '../../utils/settingsModelHelper.js' -const MODEL_SECRET_MASK = '********' +const FIXED_MODEL_SLOTS = new Set(['main', 'backup', 'embedding', 'reranker']) +const MODEL_TYPE_CAPABILITY = Object.fromEntries( + MODEL_TYPE_OPTIONS.map((option) => [option.value, option.capability]) +) -const MODEL_TEST_CONFIGS = { - main: { - label: '主模型', - providerKey: 'mainProvider', - modelKey: 'mainModel', - endpointKey: 'mainEndpoint', - apiKeyKey: 'mainApiKey', - capability: 'chat' - }, - backup: { - label: '备份模型', - providerKey: 'backupProvider', - modelKey: 'backupModel', - endpointKey: 'backupEndpoint', - apiKeyKey: 'backupApiKey', - capability: 'chat' - }, - embedding: { - label: 'Embedding 模型', - providerKey: 'embeddingProvider', - modelKey: 'embeddingModel', - endpointKey: 'embeddingEndpoint', - apiKeyKey: 'embeddingApiKey', - capability: 'embedding' - }, - reranker: { - label: 'Reranker 模型', - providerKey: 'rerankerProvider', - modelKey: 'rerankerModel', - endpointKey: 'rerankerEndpoint', - apiKeyKey: 'rerankerApiKey', - capability: 'reranker' +function buildEmptyModelDraft() { + return { + slot: '', + provider: CUSTOM_OPENAI_PROVIDER, + url: '', + apiKey: '', + apiKeyConfigured: false, + modelId: '', + type: 'llm' } } -const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible' - -const PROVIDER_ENDPOINTS = { - MiniMax: 'https://api.minimaxi.com/v1', - GLM: 'https://open.bigmodel.cn/api/paas/v4/', - Kimi: 'https://api.moonshot.ai/v1', - Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - Codex: 'https://api.openai.com/v1', - Claude: 'https://api.anthropic.com/v1/', - Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/', - [CUSTOM_OPENAI_PROVIDER]: '' +function generateModelSlot(type) { + const prefix = type === 'embedding' ? 'embedding' : type === 'rerank' ? 'rerank' : 'llm' + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + return `${prefix}_${suffix}` } -const RERANKER_PROVIDER_ENDPOINTS = { - Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank', - [CUSTOM_OPENAI_PROVIDER]: '' -} - -const LEGACY_PROVIDER_MAP = { - 'OpenAI Compatible': 'Codex', - 'Azure OpenAI': CUSTOM_OPENAI_PROVIDER, - Ollama: CUSTOM_OPENAI_PROVIDER, - '自定义网关': CUSTOM_OPENAI_PROVIDER -} - -function normalizeValue(value) { - return String(value ?? '').trim() -} - -function normalizeProviderValue(value, fallback = 'Codex') { - const normalized = normalizeValue(value) - - const providerOptions = Object.keys(PROVIDER_ENDPOINTS) - if (providerOptions.includes(normalized)) { - return normalized - } - - if (LEGACY_PROVIDER_MAP[normalized]) { - return LEGACY_PROVIDER_MAP[normalized] - } - - return fallback -} - -function getProviderEndpoint(provider) { - return PROVIDER_ENDPOINTS[provider] ?? '' -} - -function getRerankerEndpoint(provider) { - return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider) -} - -function isModelConfigReady(provider, model, endpoint) { - return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint)) -} - -function isModelSecretMask(value) { - return value === MODEL_SECRET_MASK +function normalizeDraftModel(draft) { + return normalizeLlmModelRows([ + { + slot: draft.slot || generateModelSlot(draft.type), + provider: draft.provider, + url: draft.url, + apiKey: draft.apiKey, + apiKeyConfigured: draft.apiKeyConfigured, + modelId: draft.modelId, + type: draft.type + } + ])[0] } export default { @@ -117,81 +69,170 @@ export default { }, setup(props) { const { toast } = useToast() - const modelTestState = ref({ - main: { status: 'idle', message: '' }, - backup: { status: 'idle', message: '' }, - embedding: { status: 'idle', message: '' }, - reranker: { status: 'idle', message: '' } - }) + const modelTestState = ref({}) + const modelDialogOpen = ref(false) + const editingSlot = ref('') + const modelDraft = ref(buildEmptyModelDraft()) - function applyProviderPreset(testKey) { - const config = MODEL_TEST_CONFIGS[testKey] - const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER) + const modelRows = computed(() => normalizeLlmModelRows(props.llmForm.models)) + const isEditingFixedModel = computed(() => isFixedModelSlot(editingSlot.value)) - props.llmForm[config.providerKey] = provider - props.llmForm[config.endpointKey] = - testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider) + function replaceModelRows(rows) { + props.llmForm.models = normalizeLlmModelRows(rows) } - function getModelTestState(testKey) { - return modelTestState.value[testKey] || { status: 'idle', message: '' } + function getModelTypeLabel(type) { + return MODEL_TYPE_LABELS[type] || MODEL_TYPE_LABELS.llm } - function isModelTesting(testKey) { - return getModelTestState(testKey).status === 'testing' + function isFixedModelSlot(slot) { + return FIXED_MODEL_SLOTS.has(String(slot || '')) } - function clearModelSecretMask(testKey) { - const config = MODEL_TEST_CONFIGS[testKey] - if (isModelSecretMask(props.llmForm[config.apiKeyKey])) { - props.llmForm[config.apiKeyKey] = '' + function getModelTestState(slot) { + return modelTestState.value[slot] || { status: 'idle', message: '' } + } + + function isModelTesting(slot) { + return getModelTestState(slot).status === 'testing' + } + + function openAddModelDialog() { + editingSlot.value = '' + modelDraft.value = buildEmptyModelDraft() + modelDialogOpen.value = true + } + + function openEditModelDialog(model) { + editingSlot.value = model.slot + modelDraft.value = { ...model } + modelDialogOpen.value = true + } + + function closeModelDialog() { + modelDialogOpen.value = false + editingSlot.value = '' + modelDraft.value = buildEmptyModelDraft() + } + + function applyProviderPresetToDraft() { + const provider = normalizeProviderValue(modelDraft.value.provider, CUSTOM_OPENAI_PROVIDER) + modelDraft.value.provider = provider + modelDraft.value.url = + modelDraft.value.type === 'rerank' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider) + } + + function selectDraftModelType(type) { + if (isEditingFixedModel.value) { + return + } + + modelDraft.value.type = type + applyProviderPresetToDraft() + } + + function clearDraftSecretMask() { + if (isModelSecretMask(modelDraft.value.apiKey)) { + modelDraft.value.apiKey = '' } } - async function testModelConnection(testKey) { - const config = MODEL_TEST_CONFIGS[testKey] - const provider = props.llmForm[config.providerKey] - const model = props.llmForm[config.modelKey] - const endpoint = props.llmForm[config.endpointKey] - const apiKey = props.llmForm[config.apiKeyKey] + function validateDraftModel() { + const provider = normalizeValue(modelDraft.value.provider) + const url = normalizeValue(modelDraft.value.url) + const modelId = normalizeValue(modelDraft.value.modelId) - if (!isModelConfigReady(provider, model, endpoint)) { - const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。` - modelTestState.value[testKey] = { status: 'error', message } + if (!provider || !url || !modelId) { + toast('请完整填写供应商、接口地址和 model_id。') + return false + } + + return true + } + + function saveModelDialog() { + if (!validateDraftModel()) { + return + } + + const nextModel = normalizeDraftModel(modelDraft.value) + const rows = [...modelRows.value] + const currentIndex = rows.findIndex((model) => model.slot === editingSlot.value) + + if (currentIndex >= 0) { + rows.splice(currentIndex, 1, nextModel) + } else { + rows.push(nextModel) + } + + replaceModelRows(rows) + closeModelDialog() + } + + function removeModel(model) { + if (isFixedModelSlot(model.slot)) { + toast('内置运行时槽位不能删除。') + return + } + + if (typeof window !== 'undefined' && !window.confirm('确定删除这个模型配置吗?')) { + return + } + + replaceModelRows(modelRows.value.filter((row) => row.slot !== model.slot)) + } + + async function testModelConnection(model) { + if (!normalizeValue(model.provider) || !normalizeValue(model.modelId) || !normalizeValue(model.url)) { + const message = '请先完整填写模型的供应商、model_id 和接口地址。' + modelTestState.value[model.slot] = { status: 'error', message } toast(message) return } - modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' } + modelTestState.value[model.slot] = { status: 'testing', message: '正在测试模型连通性...' } const payload = { - provider, - model, - endpoint, - api_key: isModelSecretMask(apiKey) ? '' : apiKey, - capability: config.capability, - slot: testKey + provider: model.provider, + model: model.modelId, + endpoint: model.url, + api_key: model.apiKey === MODEL_SECRET_MASK ? '' : model.apiKey, + capability: MODEL_TYPE_CAPABILITY[model.type] || 'chat', + slot: model.slot } try { const result = await testModelConnectivity(payload) - modelTestState.value[testKey] = { + modelTestState.value[model.slot] = { status: result.ok ? 'success' : 'error', message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。') } - toast(modelTestState.value[testKey].message) + toast(modelTestState.value[model.slot].message) } catch (error) { const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。' - modelTestState.value[testKey] = { status: 'error', message } + modelTestState.value[model.slot] = { status: 'error', message } toast(message) } } return { - applyProviderPreset, + MODEL_TYPE_OPTIONS, + applyProviderPresetToDraft, + clearDraftSecretMask, + closeModelDialog, getModelTestState, + getModelTypeLabel, + isEditingFixedModel, + isFixedModelSlot, isModelTesting, - clearModelSecretMask, + modelDialogOpen, + modelDraft, + modelRows, + openAddModelDialog, + openEditModelDialog, + removeModel, + saveModelDialog, + selectDraftModelType, testModelConnection } } diff --git a/web/tests/settings-llm-section.test.mjs b/web/tests/settings-llm-section.test.mjs index b6d4491..86ab8ed 100644 --- a/web/tests/settings-llm-section.test.mjs +++ b/web/tests/settings-llm-section.test.mjs @@ -7,17 +7,27 @@ const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue function testLlmSectionReplacesVlmWithReranker() { assert.doesNotMatch(settingsView, /VLM 模型/) - assert.match(llmSettingsPanel, /Reranker 模型配置/) + assert.match(llmSettingsPanel, /Rerank/) assert.match(settingsModel, /rerankerProvider/) } -function testRerankerCardRendersAfterEmbeddingCard() { - assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/) +function testLlmSectionUsesTableAndAddModelDialog() { + assert.match(llmSettingsPanel, /model-table-toolbar[\s\S]*添加模型/) + assert.match(llmSettingsPanel, //) + assert.match(llmSettingsPanel, /model-dialog-overlay/) + assert.match(llmSettingsPanel, /供应商[\s\S]*接口地址[\s\S]*API Key[\s\S]*model_id[\s\S]*模型类型/) + assert.match(llmSettingsPanel, /大语言模型[\s\S]*Embedding[\s\S]*Rerank/) +} + +function testSettingsModelKeepsExtensibleModelRows() { + assert.match(settingsModel, /models:\s*buildLlmModelRows/) + assert.match(settingsModel, /buildLlmModelRows/) } function run() { testLlmSectionReplacesVlmWithReranker() - testRerankerCardRendersAfterEmbeddingCard() + testLlmSectionUsesTableAndAddModelDialog() + testSettingsModelKeepsExtensibleModelRows() console.log('settings llm section tests passed') } diff --git a/web/tests/settings-theme-section.test.mjs b/web/tests/settings-theme-section.test.mjs new file mode 100644 index 0000000..439c3b0 --- /dev/null +++ b/web/tests/settings-theme-section.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import * as themeSkinModel from '../src/composables/useThemeSkin.js' + +const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8') +const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8') +const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8') +const aiModeStyles = readFileSync( + new URL('../src/assets/styles/components/personal-workbench-ai-mode.css', import.meta.url), + 'utf8' +) + +function testAppearanceSectionIsThemeSettings() { + assert.match(settingsModel, /id:\s*'appearance'[\s\S]*label:\s*'主题设置'/) + assert.match(settingsModel, /id:\s*'appearance'[\s\S]*title:\s*'主题风格与界面体验'/) + assert.match(settingsModel, /id:\s*'appearance'[\s\S]*actionLabel:\s*'保存主题设置'/) + assert.match(settingsView, /

主题风格与界面体验<\/h4>/) + assert.doesNotMatch(settingsModel, /label:\s*'界面皮肤'/) + assert.doesNotMatch(settingsView, /界面皮肤与企业主色/) +} + +function testThemeOptionsCollapseToThreeExperienceModes() { + assert.deepEqual( + themeSkinModel.THEME_SKIN_OPTIONS.map((theme) => theme.id), + ['vivid', 'enterprise', 'intelligent'] + ) + assert.deepEqual( + themeSkinModel.THEME_SKIN_OPTIONS.map((theme) => theme.label), + ['动感活泼', '企业沉稳', '专业智能'] + ) + assert.equal(typeof themeSkinModel.normalizeThemeMode, 'function') + assert.equal(themeSkinModel.normalizeThemeMode('sky'), 'vivid') + assert.equal(themeSkinModel.normalizeThemeMode('blue'), 'vivid') + assert.equal(themeSkinModel.normalizeThemeMode('legacy-green'), 'vivid') + assert.equal(themeSkinModel.normalizeThemeMode('navy'), 'enterprise') + assert.equal(themeSkinModel.normalizeThemeMode('slate'), 'enterprise') + assert.equal(themeSkinModel.normalizeThemeMode('soft-violet'), 'intelligent') + assert.equal(themeSkinModel.normalizeThemeMode(''), 'enterprise') + assert.equal(themeSkinModel.normalizeThemeMode('unknown-theme'), 'enterprise') +} + +function testSettingsThemeCardsAvoidLegacySkinLanguage() { + assert.match(settingsView, /theme-option-grid/) + assert.match(settingsView, /theme-preview-panel/) + assert.match(settingsStyles, /\.theme-option-grid/) + assert.match(settingsStyles, /\.theme-style-preview/) + assert.doesNotMatch(settingsView, /skin-option-grid/) + assert.doesNotMatch(settingsStyles, /\.skin-option-grid/) +} + +function testEnterpriseThemeHasAiModeOverrides() { + assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-mode\s*\{/) + assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-mode\.has-conversation\s*\{/) + assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-orb\s*\{/) + assert.match( + aiModeStyles, + /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-orb\s*\{[\s\S]*border:\s*0;[\s\S]*border-radius:\s*50%;[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*none;/ + ) + assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-composer[\s\S]*\{/) + assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-message\s*\{/) + assert.match(aiModeStyles, /\[data-theme-mode="enterprise"\]\s+\.workbench-ai-thinking-panel\s*\{/) +} + +function run() { + testAppearanceSectionIsThemeSettings() + testThemeOptionsCollapseToThreeExperienceModes() + testSettingsThemeCardsAvoidLegacySkinLanguage() + testEnterpriseThemeHasAiModeOverrides() + console.log('settings theme section tests passed') +} + +run()