feat(web): 主题皮肤系统与 LLM 设置面板重构

- useThemeSkin 重构主题皮肤应用逻辑,支持企业 AI 风格主题切换
- settingsModelHelper 新增主题与模型表字段映射,useSettings 适配
- LlmSettingsPanel/SettingsView 面板重构,支持多模型行编辑与主题区块
- settings-view.css 适配主题样式,新增 settings-theme-section 测试,更新 settings-llm-section 测试
This commit is contained in:
caoxiaozhu
2026-06-26 22:42:00 +08:00
parent 9c3fa80d22
commit 5753899eb3
9 changed files with 1099 additions and 617 deletions

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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() {

View File

@@ -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) &&

View File

@@ -1,316 +1,178 @@
<template>
<div class="model-grid">
<!-- 主模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="model-config-surface">
<section class="settings-card model-table-card">
<div class="card-head model-table-toolbar">
<div class="card-title-with-icon">
<div class="model-icon-box purple">
<i class="mdi mdi-brain"></i>
<span class="model-icon-text">AI</span>
</div>
<div>
<h4>模型配置</h4>
<p>用于 AI 助手和主业务排队调度的默认模型接入</p>
<h4>模型配置</h4>
<p>集中维护大语言模型Embedding Rerank 模型接入</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('main')"
@click="testModelConnection('main')"
>
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
<button class="add-model-button" type="button" @click="openAddModelDialog">
<i class="mdi mdi-plus"></i>
<span>添加模型</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.mainProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('main')"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.mainApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('main')"
:placeholder="llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.mainApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
<i
:class="
getModelTestState('main').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('main').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('main').message }}</span>
<div class="model-table-wrap">
<table class="model-config-table">
<thead>
<tr>
<th>模型类型</th>
<th>供应商</th>
<th>model_id</th>
<th>接口地址</th>
<th>API Key</th>
<th>连通性</th>
<th class="model-action-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="model in modelRows" :key="model.slot">
<td>
<span class="model-type-pill">
<span>{{ getModelTypeLabel(model.type) }}</span>
</span>
</td>
<td>
<strong class="model-provider-name">{{ model.provider }}</strong>
</td>
<td>
<code class="model-id-text">{{ model.modelId }}</code>
</td>
<td>
<span class="model-url-text" :title="model.url">{{ model.url }}</span>
</td>
<td>
<span class="secret-state" :class="{ configured: model.apiKeyConfigured }">
<i :class="model.apiKeyConfigured ? 'mdi mdi-database-lock' : 'mdi mdi-key-outline'"></i>
<span>{{ model.apiKeyConfigured ? '已配置' : '未配置' }}</span>
</span>
</td>
<td>
<span
v-if="getModelTestState(model.slot).message"
class="test-feedback-inline"
:class="`is-${getModelTestState(model.slot).status}`"
>
<i
:class="
getModelTestState(model.slot).status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState(model.slot).status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState(model.slot).message }}</span>
</span>
<span v-else class="test-feedback-inline is-idle">待测试</span>
</td>
<td>
<div class="model-row-actions">
<button
class="icon-action-button"
type="button"
:disabled="isModelTesting(model.slot)"
title="测试模型"
@click="testModelConnection(model)"
>
<span>{{ isModelTesting(model.slot) ? '测试中' : '测试' }}</span>
</button>
<button class="icon-action-button" type="button" title="编辑模型" @click="openEditModelDialog(model)">
<span>编辑</span>
</button>
<button
class="icon-action-button danger"
type="button"
title="删除模型"
:disabled="isFixedModelSlot(model.slot)"
@click="removeModel(model)"
>
<span>删除</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 备份模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box orange">
<i class="mdi mdi-lifebuoy"></i>
</div>
<div v-if="modelDialogOpen" class="model-dialog-overlay" @click.self="closeModelDialog">
<section class="model-dialog" role="dialog" aria-modal="true" aria-labelledby="model-dialog-title">
<header class="model-dialog-head">
<div>
<h4>备份模型配置</h4>
<p>主模型不可用或限频时用于兜底切换的备用模型接入</p>
<h4 id="model-dialog-title">{{ modelDraft.slot ? '编辑模型' : '添加模型' }}</h4>
<p>配置供应商URL密钥model_id 和模型类型</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('backup')"
@click="testModelConnection('backup')"
>
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
<button class="icon-action-button" type="button" title="关闭" @click="closeModelDialog">
<span>关闭</span>
</button>
</div>
</div>
</header>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.backupProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('backup')"
/>
</label>
<div class="form-grid model-dialog-form">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="modelDraft.provider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPresetToDraft"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
</label>
<label class="field">
<span><em>*</em> 接口地址</span>
<input v-model="modelDraft.url" type="text" placeholder="https://api.example.com/v1" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field">
<span>API Key</span>
<input
v-model="modelDraft.apiKey"
type="password"
autocomplete="off"
:placeholder="modelDraft.apiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后加密存储'"
@focus="clearDraftSecretMask"
/>
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.backupApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('backup')"
:placeholder="llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.backupApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<label class="field">
<span><em>*</em> model_id</span>
<input v-model="modelDraft.modelId" type="text" placeholder="例如 gpt-5.4-mini" />
</label>
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
<i
:class="
getModelTestState('backup').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('backup').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('backup').message }}</span>
</div>
</section>
<!-- Embedding 模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box cyan">
<i class="mdi mdi-vector-combine"></i>
</div>
<div>
<h4>Embedding 模型配置</h4>
<p>用于向量检索知识库召回和语义匹配的嵌入模型设置</p>
<div class="field field-full">
<span><em>*</em> 模型类型</span>
<div class="model-type-segment" :class="{ disabled: isEditingFixedModel }">
<button
v-for="option in MODEL_TYPE_OPTIONS"
:key="option.value"
type="button"
:class="{ active: modelDraft.type === option.value }"
:disabled="isEditingFixedModel"
@click="selectDraftModelType(option.value)"
>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('embedding')"
@click="testModelConnection('embedding')"
>
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
<footer class="model-dialog-actions">
<button class="secondary-button" type="button" @click="closeModelDialog">取消</button>
<button class="save-button compact" type="button" @click="saveModelDialog">
<span>保存模型</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.embeddingProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('embedding')"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.embeddingApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('embedding').message"
class="test-feedback"
:class="`is-${getModelTestState('embedding').status}`"
>
<i
:class="
getModelTestState('embedding').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('embedding').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('embedding').message }}</span>
</div>
</section>
<!-- Reranker 模型配置 -->
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box teal">
<i class="mdi mdi-filter-variant"></i>
</div>
<div>
<h4>Reranker 模型配置</h4>
<p>用于检索结果重排和语义精排的 Reranker 模型设置</p>
</div>
</div>
<div class="card-head-actions">
<button
class="test-button"
type="button"
:disabled="isModelTesting('reranker')"
@click="testModelConnection('reranker')"
>
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<EnterpriseSelect
v-model="llmForm.rerankerProvider"
:options="providerOptions"
placeholder="选择供应商"
@change="applyProviderPreset('reranker')"
/>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="llmForm.rerankerApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('reranker')"
:placeholder="llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('reranker').message"
class="test-feedback"
:class="`is-${getModelTestState('reranker').status}`"
>
<i
:class="
getModelTestState('reranker').status === 'success'
? 'mdi mdi-check-circle'
: getModelTestState('reranker').status === 'testing'
? 'mdi mdi-loading mdi-spin'
: 'mdi mdi-alert-circle'
"
></i>
<span>{{ getModelTestState('reranker').message }}</span>
</div>
</section>
</footer>
</section>
</div>
</div>
</template>

View File

@@ -109,44 +109,51 @@
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-palette-outline"></i>
<i class="mdi mdi-tune-variant"></i>
</div>
<div>
<h4>界面皮肤与企业主色</h4>
<p>只调整整体主色焦点态按钮和 Element Plus 控件颜色不改变业务布局</p>
<h4>主题风格与界面体验</h4>
<p>选择系统整体体验风格并联动 AI 模式的对话图标卡片和提示样式</p>
</div>
</div>
</div>
<div class="skin-option-grid">
<div class="theme-option-grid">
<button
v-for="skin in themeSkinOptions"
:key="skin.id"
class="skin-option"
:class="{ active: activeThemeSkinId === skin.id }"
v-for="theme in themeSkinOptions"
:key="theme.id"
class="theme-option"
:class="{ active: activeThemeSkinId === theme.id }"
type="button"
@click="selectThemeSkin(skin.id)"
@click="selectThemeSkin(theme.id)"
>
<span class="skin-swatch" aria-hidden="true">
<i :style="{ background: skin.primary }"></i>
<i :style="{ background: skin.primarySoftStrong }"></i>
<i :style="{ background: skin.secondary }"></i>
<i :style="{ background: skin.chartAmber }"></i>
<span class="theme-style-preview" aria-hidden="true">
<i :style="{ background: theme.primary }"></i>
<i :style="{ background: theme.primarySoftStrong }"></i>
<i :style="{ background: theme.secondary }"></i>
<i :style="{ background: theme.chartAmber }"></i>
</span>
<span class="skin-copy">
<strong>{{ skin.label }}</strong>
<small>{{ skin.desc }}</small>
<span class="theme-copy">
<strong>{{ theme.label }}</strong>
<small>{{ theme.desc }}</small>
<span class="theme-keywords">
<em v-for="keyword in theme.keywords" :key="keyword">{{ keyword }}</em>
</span>
</span>
<span v-if="activeThemeSkinId === skin.id" class="skin-current">当前</span>
<span v-if="activeThemeSkinId === theme.id" class="theme-current">当前</span>
</button>
</div>
<div class="skin-preview-panel">
<div class="theme-preview-panel">
<div>
<strong>{{ activeThemeSkin.label }}</strong>
<span>当前主会同步到全局按钮焦点环下拉浮层和表单控件</span>
<span>当前主会同步到全局按钮焦点环表单控件 AI 对话界面</span>
</div>
<div class="theme-preview-surface" aria-hidden="true">
<span></span>
<i></i>
<b></b>
</div>
<button class="skin-preview-action" type="button">主按钮</button>
</div>
</section>
</template>

View File

@@ -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
}
}

View File

@@ -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, /<table class="model-config-table">/)
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')
}

View File

@@ -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>主题风格与界面体验<\/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()