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;
|
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 {
|
.profile-grid {
|
||||||
grid-template-columns: 96px repeat(2, minmax(0, 1fr));
|
grid-template-columns: 96px repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -387,18 +712,18 @@
|
|||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-option-grid {
|
.theme-option-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-option {
|
.theme-option {
|
||||||
min-height: 104px;
|
min-height: 148px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -411,18 +736,19 @@
|
|||||||
box-shadow 160ms var(--ease);
|
box-shadow 160ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-option:hover,
|
.theme-option:hover,
|
||||||
.skin-option.active {
|
.theme-option.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--theme-primary-light-9);
|
background: #ffffff;
|
||||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-swatch {
|
.theme-style-preview {
|
||||||
width: 64px;
|
grid-column: 1 / -1;
|
||||||
height: 38px;
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.3fr 1fr 1fr 1fr;
|
grid-template-columns: 1.4fr 1fr 1fr 0.8fr;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -430,28 +756,48 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-swatch i + i {
|
.theme-style-preview i + i {
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.72);
|
border-left: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-copy {
|
.theme-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-copy strong {
|
.theme-copy strong {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-copy small {
|
.theme-copy small {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
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;
|
min-height: 24px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -464,7 +810,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-panel {
|
.theme-preview-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -472,33 +818,56 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid #d8dee8;
|
border: 1px solid #d8dee8;
|
||||||
border-radius: 4px;
|
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;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-panel strong {
|
.theme-preview-panel strong {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-panel span {
|
.theme-preview-panel span {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skin-preview-action {
|
.theme-preview-surface {
|
||||||
min-height: 34px;
|
width: min(220px, 36%);
|
||||||
padding: 0 14px;
|
min-width: 160px;
|
||||||
border: 1px solid var(--primary);
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 44px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
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);
|
background: var(--theme-gradient-primary);
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secret-bound-state {
|
.secret-bound-state {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useSystemState } from './useSystemState.js'
|
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 { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js'
|
||||||
import { useToast } from './useToast.js'
|
import { useToast } from './useToast.js'
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
maskConfiguredModelSecrets,
|
maskConfiguredModelSecrets,
|
||||||
maskConfiguredRenderSecret,
|
maskConfiguredRenderSecret,
|
||||||
mergeState,
|
mergeState,
|
||||||
|
normalizeLlmModelRows,
|
||||||
normalizeValue,
|
normalizeValue,
|
||||||
persistSettings,
|
persistSettings,
|
||||||
readStoredSettings
|
readStoredSettings
|
||||||
@@ -61,6 +62,8 @@ export function useSettings() {
|
|||||||
const cacheClearMessage = ref('')
|
const cacheClearMessage = ref('')
|
||||||
const cacheClearFailed = ref(false)
|
const cacheClearFailed = ref(false)
|
||||||
|
|
||||||
|
pageState.value.appearanceForm.themeSkin = setThemeSkin(pageState.value.appearanceForm.themeSkin)
|
||||||
|
|
||||||
const sections = SECTION_DEFINITIONS
|
const sections = SECTION_DEFINITIONS
|
||||||
const logLevels = LOG_LEVELS
|
const logLevels = LOG_LEVELS
|
||||||
const providerOptions = PROVIDER_OPTIONS
|
const providerOptions = PROVIDER_OPTIONS
|
||||||
@@ -108,6 +111,13 @@ export function useSettings() {
|
|||||||
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
||||||
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
||||||
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
|
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) {
|
if (preserveAdminPasswords) {
|
||||||
@@ -123,13 +133,16 @@ export function useSettings() {
|
|||||||
nextState.mailForm.password = currentState.mailForm.password
|
nextState.mailForm.password = currentState.mailForm.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedThemeMode = normalizeThemeMode(nextState.appearanceForm?.themeSkin)
|
||||||
|
nextState.appearanceForm = {
|
||||||
|
...nextState.appearanceForm,
|
||||||
|
themeSkin: normalizedThemeMode
|
||||||
|
}
|
||||||
|
|
||||||
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
|
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
|
||||||
persistSettings(pageState.value)
|
persistSettings(pageState.value)
|
||||||
updateBrandPreviewFromState(pageState.value)
|
updateBrandPreviewFromState(pageState.value)
|
||||||
|
setThemeSkin(normalizedThemeMode)
|
||||||
if (nextState.appearanceForm?.themeSkin) {
|
|
||||||
setThemeSkin(nextState.appearanceForm.themeSkin)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettingsSnapshot() {
|
async function loadSettingsSnapshot() {
|
||||||
@@ -358,12 +371,12 @@ export function useSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectThemeSkin(skinId) {
|
function selectThemeSkin(skinId) {
|
||||||
setThemeSkin(skinId)
|
pageState.value.appearanceForm.themeSkin = setThemeSkin(skinId)
|
||||||
pageState.value.appearanceForm.themeSkin = skinId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAppearanceSection() {
|
async function saveAppearanceSection() {
|
||||||
await persistRemoteSettings('界面皮肤已保存并应用到企业配置。', {
|
pageState.value.appearanceForm.themeSkin = normalizeThemeMode(pageState.value.appearanceForm.themeSkin)
|
||||||
|
await persistRemoteSettings('主题设置已保存并应用到企业配置。', {
|
||||||
preserveModelApiKeys: true,
|
preserveModelApiKeys: true,
|
||||||
preserveAdminPasswords: true,
|
preserveAdminPasswords: true,
|
||||||
preserveRenderSecret: true,
|
preserveRenderSecret: true,
|
||||||
@@ -373,16 +386,16 @@ export function useSettings() {
|
|||||||
|
|
||||||
async function saveLlmSection() {
|
async function saveLlmSection() {
|
||||||
const llmForm = pageState.value.llmForm
|
const llmForm = pageState.value.llmForm
|
||||||
const modelConfigs = [
|
const modelRows = normalizeLlmModelRows(llmForm.models)
|
||||||
['主模型', 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]
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [label, provider, model, endpoint] of modelConfigs) {
|
if (modelRows.length === 0) {
|
||||||
if (!isModelConfigReady(provider, model, endpoint)) {
|
toast('请至少添加一个模型配置。')
|
||||||
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of modelRows) {
|
||||||
|
if (!isModelConfigReady(row.provider, row.modelId, row.url)) {
|
||||||
|
toast('请完整填写每个模型的供应商、model_id 和接口地址。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const THEME_SKIN_STORAGE_KEY = 'x-financial-theme-skin'
|
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 = {
|
const DEFAULT_SEMANTIC_COLORS = {
|
||||||
success: '#2f855a',
|
success: '#2f855a',
|
||||||
@@ -28,112 +28,45 @@ const DEFAULT_SEMANTIC_COLORS = {
|
|||||||
|
|
||||||
export const THEME_SKIN_OPTIONS = [
|
export const THEME_SKIN_OPTIONS = [
|
||||||
{
|
{
|
||||||
id: 'sky',
|
id: 'vivid',
|
||||||
label: '浅蓝企业',
|
label: '动感活泼',
|
||||||
desc: '默认皮肤,降低蓝色饱和度,适合财务 SaaS 和审批后台。',
|
desc: '保留当前 AI 助手的明快节奏,适合演示、培训和轻量工作台。',
|
||||||
primary: '#3a7ca5',
|
keywords: ['明快', '渐变', '助手感'],
|
||||||
primaryHover: '#2f6d95',
|
primary: '#2f7cff',
|
||||||
primaryActive: '#255b7d',
|
primaryHover: '#2563eb',
|
||||||
primarySoft: '#eaf4fa',
|
primaryActive: '#1d4ed8',
|
||||||
primarySoftStrong: '#d4e8f3',
|
primarySoft: '#eef6ff',
|
||||||
secondary: '#4f6f9f',
|
primarySoftStrong: '#dbeafe',
|
||||||
chartBlue: '#4f6f9f',
|
secondary: '#7c5cff',
|
||||||
chartPurple: '#6e7fa6',
|
chartBlue: '#2f7cff',
|
||||||
chartAmber: '#b58b4c'
|
chartPurple: '#7c5cff',
|
||||||
|
chartAmber: '#f59e0b'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'blue',
|
id: 'enterprise',
|
||||||
label: '湖蓝灰',
|
label: '企业沉稳',
|
||||||
desc: '偏灰的湖蓝色,弱化科技感,更适合高密度运营页面。',
|
desc: '低饱和、轻描边、少渲染,适合正式生产环境和企业级财务 SaaS。',
|
||||||
primary: '#477c9e',
|
keywords: ['克制', '结构化', '低噪声'],
|
||||||
primaryHover: '#3a6a89',
|
primary: '#475569',
|
||||||
primaryActive: '#305873',
|
primaryHover: '#3f4a5a',
|
||||||
primarySoft: '#edf5f8',
|
primaryActive: '#334155',
|
||||||
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',
|
|
||||||
primarySoft: '#f1f5f9',
|
primarySoft: '#f1f5f9',
|
||||||
primarySoftStrong: '#e2e8f0',
|
primarySoftStrong: '#e2e8f0',
|
||||||
secondary: '#3a7ca5',
|
secondary: '#64748b',
|
||||||
chartBlue: '#5d7590',
|
chartBlue: '#5d7590',
|
||||||
chartPurple: '#77748f',
|
chartPurple: '#6b7280',
|
||||||
chartAmber: '#a88955'
|
chartAmber: '#9a7a45'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'soft-violet',
|
id: 'intelligent',
|
||||||
label: '灰紫蓝',
|
label: '专业智能',
|
||||||
desc: '保留一点智能系统气质,但用灰度压低 AI 感和饱和度。',
|
desc: '保留少量智能识别感,同时控制饱和度,适合稳定办公和 AI 辅助并重的团队。',
|
||||||
primary: '#6d6a9f',
|
keywords: ['智能', '专业', '轻点缀'],
|
||||||
primaryHover: '#5f5b8c',
|
primary: '#5f6f9f',
|
||||||
primaryActive: '#504c78',
|
primaryHover: '#53618b',
|
||||||
primarySoft: '#f2f1f8',
|
primaryActive: '#465275',
|
||||||
primarySoftStrong: '#e2e0ef',
|
primarySoft: '#f3f4fb',
|
||||||
|
primarySoftStrong: '#e2e5f4',
|
||||||
secondary: '#477c9e',
|
secondary: '#477c9e',
|
||||||
chartBlue: '#4f7495',
|
chartBlue: '#4f7495',
|
||||||
chartPurple: '#6d6a9f',
|
chartPurple: '#6d6a9f',
|
||||||
@@ -142,9 +75,36 @@ export const THEME_SKIN_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const activeThemeSkinId = ref(DEFAULT_THEME_SKIN_ID)
|
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) {
|
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) {
|
function hexToRgb(hex) {
|
||||||
@@ -185,6 +145,7 @@ function applyThemeSkin(skin) {
|
|||||||
const infoRgb = hexToRgb(DEFAULT_SEMANTIC_COLORS.info)
|
const infoRgb = hexToRgb(DEFAULT_SEMANTIC_COLORS.info)
|
||||||
|
|
||||||
root.dataset.themeSkin = skin.id
|
root.dataset.themeSkin = skin.id
|
||||||
|
root.dataset.themeMode = skin.id
|
||||||
|
|
||||||
setVariables(root, {
|
setVariables(root, {
|
||||||
'--primary': skin.primary,
|
'--primary': skin.primary,
|
||||||
@@ -270,6 +231,8 @@ export function setThemeSkin(id) {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(THEME_SKIN_STORAGE_KEY, skin.id)
|
window.localStorage.setItem(THEME_SKIN_STORAGE_KEY, skin.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return skin.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThemeSkin() {
|
export function useThemeSkin() {
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ export const SECTION_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'appearance',
|
id: 'appearance',
|
||||||
label: '界面皮肤',
|
label: '主题设置',
|
||||||
title: '界面皮肤与主色',
|
title: '主题风格与界面体验',
|
||||||
desc: '整体主色与控件观感',
|
desc: '动感、沉稳与智能风格',
|
||||||
longDesc: '设置当前浏览器的界面主色。默认使用浅蓝企业主题,后续可扩展为企业级统一下发。',
|
longDesc: '选择当前系统的整体体验风格。主题会联动全局主色、控件状态和 AI 模式的对话呈现。',
|
||||||
actionLabel: '保存皮肤设置'
|
actionLabel: '保存主题设置'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'admin',
|
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_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) => ({
|
export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
|
||||||
value: index + 1,
|
value: index + 1,
|
||||||
label: `${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) {
|
export function normalizeValue(value) {
|
||||||
return String(value ?? '').trim()
|
return String(value ?? '').trim()
|
||||||
}
|
}
|
||||||
@@ -204,6 +251,68 @@ export function getRerankerEndpoint(provider) {
|
|||||||
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(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) {
|
export function buildDefaultState(companyProfile, currentUser) {
|
||||||
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
||||||
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
|
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.`
|
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
|
||||||
},
|
},
|
||||||
appearanceForm: {
|
appearanceForm: {
|
||||||
themeSkin: 'sky'
|
themeSkin: 'enterprise'
|
||||||
},
|
},
|
||||||
adminForm: {
|
adminForm: {
|
||||||
adminAccount,
|
adminAccount,
|
||||||
@@ -260,7 +369,29 @@ export function buildDefaultState(companyProfile, currentUser) {
|
|||||||
rerankerModel: 'gte-rerank-v2',
|
rerankerModel: 'gte-rerank-v2',
|
||||||
rerankerEndpoint: getRerankerEndpoint('Ali'),
|
rerankerEndpoint: getRerankerEndpoint('Ali'),
|
||||||
rerankerApiKey: '',
|
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: {
|
renderForm: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -326,6 +457,7 @@ export function mergeState(baseState, overrideState) {
|
|||||||
mergedLlmForm.rerankerProvider,
|
mergedLlmForm.rerankerProvider,
|
||||||
baseState.llmForm.rerankerProvider
|
baseState.llmForm.rerankerProvider
|
||||||
)
|
)
|
||||||
|
mergedLlmForm.models = buildLlmModelRows(mergedLlmForm)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
|
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
|
||||||
@@ -355,11 +487,15 @@ export function sanitizeForStorage(state) {
|
|||||||
sessionForm: { ...state.sessionForm },
|
sessionForm: { ...state.sessionForm },
|
||||||
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
|
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
|
||||||
llmForm: {
|
llmForm: {
|
||||||
...state.llmForm,
|
...syncLegacyModelFieldsFromRows(state.llmForm),
|
||||||
mainApiKey: '',
|
mainApiKey: '',
|
||||||
backupApiKey: '',
|
backupApiKey: '',
|
||||||
embeddingApiKey: '',
|
embeddingApiKey: '',
|
||||||
rerankerApiKey: ''
|
rerankerApiKey: '',
|
||||||
|
models: normalizeLlmModelRows(state.llmForm.models).map((row) => ({
|
||||||
|
...row,
|
||||||
|
apiKey: ''
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
renderForm: {
|
renderForm: {
|
||||||
...state.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
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLlmPayload(llmForm) {
|
export function buildLlmPayload(llmForm) {
|
||||||
const payload = { ...llmForm }
|
const payload = syncLegacyModelFieldsFromRows({
|
||||||
|
...llmForm,
|
||||||
|
models: normalizeLlmModelRows(llmForm.models)
|
||||||
|
})
|
||||||
|
|
||||||
for (const config of MODEL_API_KEY_CONFIGS) {
|
for (const config of MODEL_API_KEY_CONFIGS) {
|
||||||
if (isModelSecretMask(payload[config.apiKeyKey])) {
|
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
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,20 +608,13 @@ export function computeSectionStatus(state) {
|
|||||||
Number(state.sessionForm.conversationRetentionDays) <= 10
|
Number(state.sessionForm.conversationRetentionDays) <= 10
|
||||||
),
|
),
|
||||||
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
|
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
|
||||||
llm: Boolean(
|
llm: (() => {
|
||||||
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
|
const rows = normalizeLlmModelRows(state.llmForm.models)
|
||||||
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
|
return Boolean(
|
||||||
isModelConfigReady(
|
rows.length > 0 &&
|
||||||
state.llmForm.embeddingProvider,
|
rows.every((row) => isModelConfigReady(row.provider, row.modelId, row.url))
|
||||||
state.llmForm.embeddingModel,
|
)
|
||||||
state.llmForm.embeddingEndpoint
|
})(),
|
||||||
) &&
|
|
||||||
isModelConfigReady(
|
|
||||||
state.llmForm.rerankerProvider,
|
|
||||||
state.llmForm.rerankerModel,
|
|
||||||
state.llmForm.rerankerEndpoint
|
|
||||||
)
|
|
||||||
),
|
|
||||||
rendering: Boolean(
|
rendering: Boolean(
|
||||||
!state.renderForm.enabled ||
|
!state.renderForm.enabled ||
|
||||||
(normalizeValue(state.renderForm.publicUrl) &&
|
(normalizeValue(state.renderForm.publicUrl) &&
|
||||||
|
|||||||
@@ -1,316 +1,178 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="model-grid">
|
<div class="model-config-surface">
|
||||||
<!-- 主模型配置 -->
|
<section class="settings-card model-table-card">
|
||||||
<section class="settings-card">
|
<div class="card-head model-table-toolbar">
|
||||||
<div class="card-head">
|
|
||||||
<div class="card-title-with-icon">
|
<div class="card-title-with-icon">
|
||||||
<div class="model-icon-box purple">
|
<div class="model-icon-box purple">
|
||||||
<i class="mdi mdi-brain"></i>
|
<span class="model-icon-text">AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>主模型配置</h4>
|
<h4>模型配置</h4>
|
||||||
<p>用于 AI 助手和主业务排队调度的默认模型接入。</p>
|
<p>集中维护大语言模型、Embedding 和 Rerank 模型接入。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-head-actions">
|
<div class="card-head-actions">
|
||||||
<button
|
<button class="add-model-button" type="button" @click="openAddModelDialog">
|
||||||
class="test-button"
|
<i class="mdi mdi-plus"></i>
|
||||||
type="button"
|
<span>添加模型</span>
|
||||||
:disabled="isModelTesting('main')"
|
|
||||||
@click="testModelConnection('main')"
|
|
||||||
>
|
|
||||||
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="model-table-wrap">
|
||||||
<label class="field">
|
<table class="model-config-table">
|
||||||
<span><em>*</em> 供应商</span>
|
<thead>
|
||||||
<EnterpriseSelect
|
<tr>
|
||||||
v-model="llmForm.mainProvider"
|
<th>模型类型</th>
|
||||||
:options="providerOptions"
|
<th>供应商</th>
|
||||||
placeholder="选择供应商"
|
<th>model_id</th>
|
||||||
@change="applyProviderPreset('main')"
|
<th>接口地址</th>
|
||||||
/>
|
<th>API Key</th>
|
||||||
</label>
|
<th>连通性</th>
|
||||||
|
<th class="model-action-col">操作</th>
|
||||||
<label class="field">
|
</tr>
|
||||||
<span><em>*</em> 模型名称</span>
|
</thead>
|
||||||
<input v-model="llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
|
<tbody>
|
||||||
</label>
|
<tr v-for="model in modelRows" :key="model.slot">
|
||||||
|
<td>
|
||||||
<label class="field field-full">
|
<span class="model-type-pill">
|
||||||
<span><em>*</em> 接口地址</span>
|
<span>{{ getModelTypeLabel(model.type) }}</span>
|
||||||
<input v-model="llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
|
</span>
|
||||||
</label>
|
</td>
|
||||||
|
<td>
|
||||||
<label class="field field-full">
|
<strong class="model-provider-name">{{ model.provider }}</strong>
|
||||||
<span>API Key</span>
|
</td>
|
||||||
<input
|
<td>
|
||||||
v-model="llmForm.mainApiKey"
|
<code class="model-id-text">{{ model.modelId }}</code>
|
||||||
type="password"
|
</td>
|
||||||
autocomplete="off"
|
<td>
|
||||||
@focus="clearModelSecretMask('main')"
|
<span class="model-url-text" :title="model.url">{{ model.url }}</span>
|
||||||
:placeholder="llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
</td>
|
||||||
/>
|
<td>
|
||||||
<small v-if="llmForm.mainApiKeyConfigured" class="secret-bound-state">
|
<span class="secret-state" :class="{ configured: model.apiKeyConfigured }">
|
||||||
<i class="mdi mdi-database-lock"></i>
|
<i :class="model.apiKeyConfigured ? 'mdi mdi-database-lock' : 'mdi mdi-key-outline'"></i>
|
||||||
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
<span>{{ model.apiKeyConfigured ? '已配置' : '未配置' }}</span>
|
||||||
</small>
|
</span>
|
||||||
</label>
|
</td>
|
||||||
</div>
|
<td>
|
||||||
|
<span
|
||||||
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
|
v-if="getModelTestState(model.slot).message"
|
||||||
<i
|
class="test-feedback-inline"
|
||||||
:class="
|
:class="`is-${getModelTestState(model.slot).status}`"
|
||||||
getModelTestState('main').status === 'success'
|
>
|
||||||
? 'mdi mdi-check-circle'
|
<i
|
||||||
: getModelTestState('main').status === 'testing'
|
:class="
|
||||||
? 'mdi mdi-loading mdi-spin'
|
getModelTestState(model.slot).status === 'success'
|
||||||
: 'mdi mdi-alert-circle'
|
? 'mdi mdi-check-circle'
|
||||||
"
|
: getModelTestState(model.slot).status === 'testing'
|
||||||
></i>
|
? 'mdi mdi-loading mdi-spin'
|
||||||
<span>{{ getModelTestState('main').message }}</span>
|
: '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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 备份模型配置 -->
|
<div v-if="modelDialogOpen" class="model-dialog-overlay" @click.self="closeModelDialog">
|
||||||
<section class="settings-card">
|
<section class="model-dialog" role="dialog" aria-modal="true" aria-labelledby="model-dialog-title">
|
||||||
<div class="card-head">
|
<header class="model-dialog-head">
|
||||||
<div class="card-title-with-icon">
|
|
||||||
<div class="model-icon-box orange">
|
|
||||||
<i class="mdi mdi-lifebuoy"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h4>备份模型配置</h4>
|
<h4 id="model-dialog-title">{{ modelDraft.slot ? '编辑模型' : '添加模型' }}</h4>
|
||||||
<p>主模型不可用或限频时用于兜底切换的备用模型接入。</p>
|
<p>配置供应商、URL、密钥、model_id 和模型类型。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="icon-action-button" type="button" title="关闭" @click="closeModelDialog">
|
||||||
<div class="card-head-actions">
|
<span>关闭</span>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid model-dialog-form">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span><em>*</em> 供应商</span>
|
<span><em>*</em> 供应商</span>
|
||||||
<EnterpriseSelect
|
<EnterpriseSelect
|
||||||
v-model="llmForm.backupProvider"
|
v-model="modelDraft.provider"
|
||||||
:options="providerOptions"
|
:options="providerOptions"
|
||||||
placeholder="选择供应商"
|
placeholder="选择供应商"
|
||||||
@change="applyProviderPreset('backup')"
|
@change="applyProviderPresetToDraft"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span><em>*</em> 模型名称</span>
|
<span><em>*</em> 接口地址</span>
|
||||||
<input v-model="llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
|
<input v-model="modelDraft.url" type="text" placeholder="https://api.example.com/v1" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-full">
|
<label class="field">
|
||||||
<span><em>*</em> 接口地址</span>
|
<span>API Key</span>
|
||||||
<input v-model="llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
|
<input
|
||||||
</label>
|
v-model="modelDraft.apiKey"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="modelDraft.apiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后加密存储'"
|
||||||
|
@focus="clearDraftSecretMask"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="field field-full">
|
<label class="field">
|
||||||
<span>API Key</span>
|
<span><em>*</em> model_id</span>
|
||||||
<input
|
<input v-model="modelDraft.modelId" type="text" placeholder="例如 gpt-5.4-mini" />
|
||||||
v-model="llmForm.backupApiKey"
|
</label>
|
||||||
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>
|
|
||||||
|
|
||||||
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
|
<div class="field field-full">
|
||||||
<i
|
<span><em>*</em> 模型类型</span>
|
||||||
:class="
|
<div class="model-type-segment" :class="{ disabled: isEditingFixedModel }">
|
||||||
getModelTestState('backup').status === 'success'
|
<button
|
||||||
? 'mdi mdi-check-circle'
|
v-for="option in MODEL_TYPE_OPTIONS"
|
||||||
: getModelTestState('backup').status === 'testing'
|
:key="option.value"
|
||||||
? 'mdi mdi-loading mdi-spin'
|
type="button"
|
||||||
: 'mdi mdi-alert-circle'
|
:class="{ active: modelDraft.type === option.value }"
|
||||||
"
|
:disabled="isEditingFixedModel"
|
||||||
></i>
|
@click="selectDraftModelType(option.value)"
|
||||||
<span>{{ getModelTestState('backup').message }}</span>
|
>
|
||||||
</div>
|
<span>{{ option.label }}</span>
|
||||||
</section>
|
</button>
|
||||||
|
</div>
|
||||||
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-head-actions">
|
|
||||||
<button
|
<footer class="model-dialog-actions">
|
||||||
class="test-button"
|
<button class="secondary-button" type="button" @click="closeModelDialog">取消</button>
|
||||||
type="button"
|
<button class="save-button compact" type="button" @click="saveModelDialog">
|
||||||
:disabled="isModelTesting('embedding')"
|
<span>保存模型</span>
|
||||||
@click="testModelConnection('embedding')"
|
|
||||||
>
|
|
||||||
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
</section>
|
||||||
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -109,44 +109,51 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="card-title-with-icon">
|
<div class="card-title-with-icon">
|
||||||
<div class="model-icon-box slate">
|
<div class="model-icon-box slate">
|
||||||
<i class="mdi mdi-palette-outline"></i>
|
<i class="mdi mdi-tune-variant"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>界面皮肤与企业主色</h4>
|
<h4>主题风格与界面体验</h4>
|
||||||
<p>只调整整体主色、焦点态、按钮和 Element Plus 控件颜色,不改变业务布局。</p>
|
<p>选择系统整体体验风格,并联动 AI 模式的对话、图标、卡片和提示样式。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="skin-option-grid">
|
<div class="theme-option-grid">
|
||||||
<button
|
<button
|
||||||
v-for="skin in themeSkinOptions"
|
v-for="theme in themeSkinOptions"
|
||||||
:key="skin.id"
|
:key="theme.id"
|
||||||
class="skin-option"
|
class="theme-option"
|
||||||
:class="{ active: activeThemeSkinId === skin.id }"
|
:class="{ active: activeThemeSkinId === theme.id }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectThemeSkin(skin.id)"
|
@click="selectThemeSkin(theme.id)"
|
||||||
>
|
>
|
||||||
<span class="skin-swatch" aria-hidden="true">
|
<span class="theme-style-preview" aria-hidden="true">
|
||||||
<i :style="{ background: skin.primary }"></i>
|
<i :style="{ background: theme.primary }"></i>
|
||||||
<i :style="{ background: skin.primarySoftStrong }"></i>
|
<i :style="{ background: theme.primarySoftStrong }"></i>
|
||||||
<i :style="{ background: skin.secondary }"></i>
|
<i :style="{ background: theme.secondary }"></i>
|
||||||
<i :style="{ background: skin.chartAmber }"></i>
|
<i :style="{ background: theme.chartAmber }"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="skin-copy">
|
<span class="theme-copy">
|
||||||
<strong>{{ skin.label }}</strong>
|
<strong>{{ theme.label }}</strong>
|
||||||
<small>{{ skin.desc }}</small>
|
<small>{{ theme.desc }}</small>
|
||||||
|
<span class="theme-keywords">
|
||||||
|
<em v-for="keyword in theme.keywords" :key="keyword">{{ keyword }}</em>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="activeThemeSkinId === skin.id" class="skin-current">当前</span>
|
<span v-if="activeThemeSkinId === theme.id" class="theme-current">当前</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="skin-preview-panel">
|
<div class="theme-preview-panel">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ activeThemeSkin.label }}</strong>
|
<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>
|
</div>
|
||||||
<button class="skin-preview-action" type="button">主按钮</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,103 +1,55 @@
|
|||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { testModelConnectivity } from '../../services/settings.js'
|
import { testModelConnectivity } from '../../services/settings.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
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 = {
|
function buildEmptyModelDraft() {
|
||||||
main: {
|
return {
|
||||||
label: '主模型',
|
slot: '',
|
||||||
providerKey: 'mainProvider',
|
provider: CUSTOM_OPENAI_PROVIDER,
|
||||||
modelKey: 'mainModel',
|
url: '',
|
||||||
endpointKey: 'mainEndpoint',
|
apiKey: '',
|
||||||
apiKeyKey: 'mainApiKey',
|
apiKeyConfigured: false,
|
||||||
capability: 'chat'
|
modelId: '',
|
||||||
},
|
type: 'llm'
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
|
function generateModelSlot(type) {
|
||||||
|
const prefix = type === 'embedding' ? 'embedding' : type === 'rerank' ? 'rerank' : 'llm'
|
||||||
const PROVIDER_ENDPOINTS = {
|
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||||
MiniMax: 'https://api.minimaxi.com/v1',
|
return `${prefix}_${suffix}`
|
||||||
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]: ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RERANKER_PROVIDER_ENDPOINTS = {
|
function normalizeDraftModel(draft) {
|
||||||
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
|
return normalizeLlmModelRows([
|
||||||
[CUSTOM_OPENAI_PROVIDER]: ''
|
{
|
||||||
}
|
slot: draft.slot || generateModelSlot(draft.type),
|
||||||
|
provider: draft.provider,
|
||||||
const LEGACY_PROVIDER_MAP = {
|
url: draft.url,
|
||||||
'OpenAI Compatible': 'Codex',
|
apiKey: draft.apiKey,
|
||||||
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
|
apiKeyConfigured: draft.apiKeyConfigured,
|
||||||
Ollama: CUSTOM_OPENAI_PROVIDER,
|
modelId: draft.modelId,
|
||||||
'自定义网关': CUSTOM_OPENAI_PROVIDER
|
type: draft.type
|
||||||
}
|
}
|
||||||
|
])[0]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -117,81 +69,170 @@ export default {
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const modelTestState = ref({
|
const modelTestState = ref({})
|
||||||
main: { status: 'idle', message: '' },
|
const modelDialogOpen = ref(false)
|
||||||
backup: { status: 'idle', message: '' },
|
const editingSlot = ref('')
|
||||||
embedding: { status: 'idle', message: '' },
|
const modelDraft = ref(buildEmptyModelDraft())
|
||||||
reranker: { status: 'idle', message: '' }
|
|
||||||
})
|
|
||||||
|
|
||||||
function applyProviderPreset(testKey) {
|
const modelRows = computed(() => normalizeLlmModelRows(props.llmForm.models))
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
const isEditingFixedModel = computed(() => isFixedModelSlot(editingSlot.value))
|
||||||
const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
|
|
||||||
|
|
||||||
props.llmForm[config.providerKey] = provider
|
function replaceModelRows(rows) {
|
||||||
props.llmForm[config.endpointKey] =
|
props.llmForm.models = normalizeLlmModelRows(rows)
|
||||||
testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelTestState(testKey) {
|
function getModelTypeLabel(type) {
|
||||||
return modelTestState.value[testKey] || { status: 'idle', message: '' }
|
return MODEL_TYPE_LABELS[type] || MODEL_TYPE_LABELS.llm
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModelTesting(testKey) {
|
function isFixedModelSlot(slot) {
|
||||||
return getModelTestState(testKey).status === 'testing'
|
return FIXED_MODEL_SLOTS.has(String(slot || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearModelSecretMask(testKey) {
|
function getModelTestState(slot) {
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
return modelTestState.value[slot] || { status: 'idle', message: '' }
|
||||||
if (isModelSecretMask(props.llmForm[config.apiKeyKey])) {
|
}
|
||||||
props.llmForm[config.apiKeyKey] = ''
|
|
||||||
|
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) {
|
function validateDraftModel() {
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
const provider = normalizeValue(modelDraft.value.provider)
|
||||||
const provider = props.llmForm[config.providerKey]
|
const url = normalizeValue(modelDraft.value.url)
|
||||||
const model = props.llmForm[config.modelKey]
|
const modelId = normalizeValue(modelDraft.value.modelId)
|
||||||
const endpoint = props.llmForm[config.endpointKey]
|
|
||||||
const apiKey = props.llmForm[config.apiKeyKey]
|
|
||||||
|
|
||||||
if (!isModelConfigReady(provider, model, endpoint)) {
|
if (!provider || !url || !modelId) {
|
||||||
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
|
toast('请完整填写供应商、接口地址和 model_id。')
|
||||||
modelTestState.value[testKey] = { status: 'error', message }
|
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)
|
toast(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
|
modelTestState.value[model.slot] = { status: 'testing', message: '正在测试模型连通性...' }
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
provider,
|
provider: model.provider,
|
||||||
model,
|
model: model.modelId,
|
||||||
endpoint,
|
endpoint: model.url,
|
||||||
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
|
api_key: model.apiKey === MODEL_SECRET_MASK ? '' : model.apiKey,
|
||||||
capability: config.capability,
|
capability: MODEL_TYPE_CAPABILITY[model.type] || 'chat',
|
||||||
slot: testKey
|
slot: model.slot
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await testModelConnectivity(payload)
|
const result = await testModelConnectivity(payload)
|
||||||
modelTestState.value[testKey] = {
|
modelTestState.value[model.slot] = {
|
||||||
status: result.ok ? 'success' : 'error',
|
status: result.ok ? 'success' : 'error',
|
||||||
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
||||||
}
|
}
|
||||||
toast(modelTestState.value[testKey].message)
|
toast(modelTestState.value[model.slot].message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
||||||
modelTestState.value[testKey] = { status: 'error', message }
|
modelTestState.value[model.slot] = { status: 'error', message }
|
||||||
toast(message)
|
toast(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyProviderPreset,
|
MODEL_TYPE_OPTIONS,
|
||||||
|
applyProviderPresetToDraft,
|
||||||
|
clearDraftSecretMask,
|
||||||
|
closeModelDialog,
|
||||||
getModelTestState,
|
getModelTestState,
|
||||||
|
getModelTypeLabel,
|
||||||
|
isEditingFixedModel,
|
||||||
|
isFixedModelSlot,
|
||||||
isModelTesting,
|
isModelTesting,
|
||||||
clearModelSecretMask,
|
modelDialogOpen,
|
||||||
|
modelDraft,
|
||||||
|
modelRows,
|
||||||
|
openAddModelDialog,
|
||||||
|
openEditModelDialog,
|
||||||
|
removeModel,
|
||||||
|
saveModelDialog,
|
||||||
|
selectDraftModelType,
|
||||||
testModelConnection
|
testModelConnection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue
|
|||||||
|
|
||||||
function testLlmSectionReplacesVlmWithReranker() {
|
function testLlmSectionReplacesVlmWithReranker() {
|
||||||
assert.doesNotMatch(settingsView, /VLM 模型/)
|
assert.doesNotMatch(settingsView, /VLM 模型/)
|
||||||
assert.match(llmSettingsPanel, /Reranker 模型配置/)
|
assert.match(llmSettingsPanel, /Rerank/)
|
||||||
assert.match(settingsModel, /rerankerProvider/)
|
assert.match(settingsModel, /rerankerProvider/)
|
||||||
}
|
}
|
||||||
|
|
||||||
function testRerankerCardRendersAfterEmbeddingCard() {
|
function testLlmSectionUsesTableAndAddModelDialog() {
|
||||||
assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
|
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() {
|
function run() {
|
||||||
testLlmSectionReplacesVlmWithReranker()
|
testLlmSectionReplacesVlmWithReranker()
|
||||||
testRerankerCardRendersAfterEmbeddingCard()
|
testLlmSectionUsesTableAndAddModelDialog()
|
||||||
|
testSettingsModelKeepsExtensibleModelRows()
|
||||||
console.log('settings llm section tests passed')
|
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