feat(web): 主题皮肤系统与 LLM 设置面板重构
- useThemeSkin 重构主题皮肤应用逻辑,支持企业 AI 风格主题切换 - settingsModelHelper 新增主题与模型表字段映射,useSettings 适配 - LlmSettingsPanel/SettingsView 面板重构,支持多模型行编辑与主题区块 - settings-view.css 适配主题样式,新增 settings-theme-section 测试,更新 settings-llm-section 测试
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
73
web/tests/settings-theme-section.test.mjs
Normal file
73
web/tests/settings-theme-section.test.mjs
Normal 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()
|
||||
Reference in New Issue
Block a user