优化: Settings 前端(Provider 模型表格化手动标注 + 布局重构 + 连接密码字段)

This commit is contained in:
lxy
2026-08-08 14:19:49 +08:00
parent b408b2d7a1
commit 92903702e2
6 changed files with 424 additions and 75 deletions
+88 -15
View File
@@ -6,30 +6,49 @@
</div>
<div class="general-settings">
<!-- 应用数据目录(运行期确定,跨平台) -->
<SettingRow :label="$t('settings.labelDataDir')" :desc="$t('settings.descDataDir')">
<!-- 布局与 select 行统一:label + 路径居右同一行,desc label 下方 -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelDataDir') }}</span>
<span class="data-dir-path" :title="dataDir">{{ dataDir || $t('settings.loading') }}</span>
</SettingRow>
</div>
<span class="setting-desc">{{ $t('settings.descDataDir') }}</span>
</div>
<!-- F-#97:三档 low/medium/alllow=仅低风险自动(等价旧 autoExecute=false);
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
<!-- inline-control-row:自定义布局label select 同一行(靠左紧挨,顶对齐),
desc 换行到整行下方不复用 SettingRow(grid/contents 方案渲染错乱) -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelAutoExecute') }}</span>
<select v-model="settings.autoExecuteMode" class="setting-select" @change="onAutoModeChange">
<option value="low">{{ $t('settings.autoModeLow') }}</option>
<option value="medium">{{ $t('settings.autoModeMedium') }}</option>
<option value="all">{{ $t('settings.autoModeAll') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
<span v-if="savedVisible.autoExecute" class="saved-hint">{{ $t('settings.savedHint') }}</span>
</div>
<span class="setting-desc">{{ $t('settings.descAutoExecute') }}</span>
</div>
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelLogLevel') }}</span>
<select v-model="settings.logLevel" class="setting-select" @change="onLogLevelChange">
<option value="error">Error</option>
<option value="warn">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</SettingRow>
<span v-if="savedVisible.logLevel" class="saved-hint">{{ $t('settings.savedHint') }}</span>
</div>
<span class="setting-desc">{{ $t('settings.descLogLevel') }}</span>
</div>
<!-- 审批超时:用户未处理审批弹窗时的自动拒绝阅值;0 表示不限时(对齐旧默认) -->
<SettingRow ref="rowApprovalTimeout" :label="$t('settings.labelApprovalTimeout')" :desc="$t('settings.descApprovalTimeout')">
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelApprovalTimeout') }}</span>
<select v-model="settings.approvalTimeout" class="setting-select" @change="onApprovalTimeoutChange">
<option :value="0">{{ $t('settings.approvalTimeoutUnlimited') }}</option>
<option :value="300000">{{ $t('settings.approvalTimeout5m') }}</option>
@@ -37,9 +56,13 @@
<option :value="1800000">{{ $t('settings.approvalTimeout30m') }}</option>
<option :value="3600000">{{ $t('settings.approvalTimeout60m') }}</option>
</select>
</SettingRow>
<span v-if="savedVisible.approvalTimeout" class="saved-hint">{{ $t('settings.savedHint') }}</span>
</div>
<span class="setting-desc">{{ $t('settings.descApprovalTimeout') }}</span>
</div>
<!-- 自定义提示词:注入到 AI 系统 prompt 末尾,用户可自定义 AI 行为偏好 -->
<SettingRow ref="rowCustomPrompt" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')">
<!-- 布局:整行堆叠label/desc 在上,textarea 独立一行从左到右拉满(而非 label + 框右) -->
<SettingRow ref="rowCustomPrompt" class="prompt-row" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')">
<textarea
v-model="customPrompt"
class="setting-textarea"
@@ -130,10 +153,22 @@ const settings = reactive({
})
const customPrompt = ref(appSettings.get<string>('custom_prompt', ''))
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
// 三个 select 行改为自定义布局(不复用 SettingRow),saved-hint 由本地 savedVisible 状态驱动
const rowCustomPrompt = useTemplateRef<InstanceType<typeof SettingRow>>('rowCustomPrompt')
const rowApprovalTimeout = useTemplateRef<InstanceType<typeof SettingRow>>('rowApprovalTimeout')
// 「已保存」提示可见性(2s 自动隐藏),key 对齐模板三行
const savedVisible = reactive<Record<string, boolean>>({
autoExecute: false,
logLevel: false,
approvalTimeout: false,
})
const savedTimers = new Map<string, ReturnType<typeof setTimeout>>()
function flashSaved(key: string) {
savedVisible[key] = true
const prev = savedTimers.get(key)
if (prev) clearTimeout(prev)
savedTimers.set(key, setTimeout(() => { savedVisible[key] = false }, 2000))
}
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
// 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。
@@ -153,7 +188,7 @@ async function onAutoModeChange() {
}
appSettings.set('df-ai-auto-execute-mode', settings.autoExecuteMode)
lastConfirmedMode = settings.autoExecuteMode
rowAutoExecute.value?.markSaved()
flashSaved('autoExecute')
}
// watch 兜底:非 all 切换直接落 KV + 更新 lastConfirmedMode(all 走 onAutoModeChange 确认后落,
@@ -174,7 +209,7 @@ watch(
// (本任务范围只修「假保存」,运行时生效链路另行评估,不擅自扩展)。
function onLogLevelChange() {
void appSettings.set('df-log-level', settings.logLevel)
rowLogLevel.value?.markSaved()
flashSaved('logLevel')
}
// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。
@@ -185,7 +220,7 @@ function onCustomPromptChange() {
function onApprovalTimeoutChange() {
appSettings.set('df-approval-timeout', settings.approvalTimeout)
rowApprovalTimeout.value?.markSaved()
flashSaved('approvalTimeout')
}
</script>
@@ -197,9 +232,47 @@ function onApprovalTimeoutChange() {
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.data-dir-path {
flex: 1;
min-width: 0;
margin-left: 24px;
font-size: 12px;
font-family: var(--df-font-mono);
color: var(--df-text-dim);
text-align: right;
word-break: break-all;
line-height: 1.4;
}
/* 自定义提示词行:整行堆叠,textarea 独立一行从左到右拉满。
覆盖 SettingRow 默认横排(label 左 + control 右)。 */
.prompt-row {
flex-direction: column;
align-items: stretch;
}
.prompt-row .setting-info { align-items: flex-start; }
.prompt-row .setting-control { align-items: flex-start; }
/* select 类设置行(自定义布局,不复用 SettingRow):label 与 select 同一行靠左顶对齐,
desc 换行到整行下方。
┌───────────────────────────────┐
│ label [select] ✓已保存 │
│ desc(介绍信息,整行) │
└───────────────────────────────┘ */
.inline-control-row {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.inline-head {
display: flex;
align-items: center;
gap: 12px;
}
.inline-head .setting-label { flex: 0 0 auto; }
.inline-head .setting-select { flex: 0 0 auto; margin-left: auto; }
.saved-hint {
margin-left: 4px;
font-size: 12px;
color: var(--df-accent);
white-space: nowrap;
}
.confirm-mask {
position: fixed;
+38 -9
View File
@@ -43,25 +43,32 @@
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelType') }}</label>
<select v-model="connForm.type" class="setting-select">
<select v-model="connForm.type" class="setting-select" @change="onTypeChange">
<option value="mysql">MySQL</option>
<option value="ssh">SSH</option>
<option value="redis">Redis</option>
<option value="mongo">MongoDB</option>
</select>
</div>
<div class="form-field">
<!-- host + port 同一行(端口跟在 host 后面,符合 host:port 地址习惯) -->
<div class="form-row">
<div class="form-field form-field--host">
<label class="form-label">{{ $t('settings.labelHost') }}</label>
<input v-model="connForm.host" class="setting-input" :placeholder="$t('settings.phHost')" />
</div>
<div class="form-field">
<div class="form-field form-field--port">
<label class="form-label">{{ $t('settings.labelPort') }}</label>
<input v-model.number="connForm.port" class="setting-input" type="number" min="1" max="65535" :placeholder="$t('settings.phPort')" />
<input v-model.number="connForm.port" class="setting-number" type="number" min="1" max="65535" :placeholder="$t('settings.phPort')" />
</div>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelUser') }}</label>
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelPassword') }}</label>
<input v-model="connForm.password" class="setting-input" type="password" :placeholder="$t('settings.phPassword')" autocomplete="new-password" />
</div>
<div class="form-actions">
<button class="btn btn-primary" :disabled="submitting" @click="saveConn">{{ $t('common.save') }}</button>
</div>
@@ -96,18 +103,23 @@ interface ConnRecord {
host: string
port: number
user: string
password: string
}
const connections = ref<ConnRecord[]>([])
// 各类型连接的默认端口(用户切换类型时自动带入,单一来源)
const DEFAULT_PORTS: Record<string, number> = { mysql: 3306, ssh: 22, redis: 6379, mongo: 27017 }
const connForm = reactive({
visible: false,
editId: '' as string,
name: '',
type: 'mysql',
host: '',
port: 3306,
port: DEFAULT_PORTS.mysql,
user: '',
password: '',
})
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
@@ -142,17 +154,25 @@ function openConnForm(conn?: ConnRecord) {
connForm.host = conn.host
connForm.port = conn.port
connForm.user = conn.user
connForm.password = conn.password ?? ''
} else {
connForm.editId = ''
connForm.name = ''
connForm.type = 'mysql'
connForm.host = ''
connForm.port = 3306
connForm.port = DEFAULT_PORTS[connForm.type] ?? 3306
connForm.user = ''
connForm.password = ''
}
connForm.visible = true
}
/** 用户手动切换连接类型时,端口自动重置为该类型默认端口。
* 用 @change(仅用户交互触发)而非 watch:避免编辑已有连接回填类型时误覆盖已存端口。 */
function onTypeChange(): void {
connForm.port = DEFAULT_PORTS[connForm.type] ?? 3306
}
function saveConn() {
if (!connForm.name || !connForm.host) {
emit('toast', t('settings.toastConnIncomplete'), 'warning')
@@ -173,6 +193,7 @@ function saveConn() {
host: connForm.host,
port: connForm.port,
user: connForm.user,
password: connForm.password,
}
if (connForm.editId) {
const idx = connections.value.findIndex(c => c.id === connForm.editId)
@@ -269,10 +290,18 @@ onMounted(loadConnections)
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
.form-field { display: flex; flex-direction: row; align-items: center; gap: 10px; }
.form-label { flex: 0 0 44px; font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
.setting-input { flex: 1; min-width: 0; padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; }
.setting-input:focus { border-color: var(--df-accent); }
.form-field .setting-select { flex: 1; min-width: 0; }
/* host+port 组合行(端口跟在 host 后;min-width:0 允许收缩防窄面板溢出,label 已缩窄控制距离) */
.form-row { display: flex; gap: 6px; }
.form-row .form-field { min-width: 0; }
.form-row .form-field--host { flex: 3; }
.form-row .form-field--port { flex: 2; }
/* port 数字框复用 .setting-number(隐藏原生箭头);在 form-row 内覆盖 flex 填满列宽 */
.form-row .setting-number { flex: 1; min-width: 0; }
</style>
+238 -19
View File
@@ -119,26 +119,56 @@
<!-- 拉取到的模型列表(F-01 阶段6) -->
<div class="form-field form-field--full" v-if="providerForm.models.length > 0">
<label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label>
<div class="model-list">
<div class="model-row" v-for="m in providerForm.models" :key="m.model_id">
<div class="model-row-main">
<table class="model-table">
<thead>
<tr>
<th class="col-enabled">{{ $t('settings.colEnabled') }}</th>
<th class="col-model">{{ $t('settings.colModel') }}</th>
<th class="col-ability">{{ $t('settings.colAbility') }}</th>
<th class="col-weight">{{ $t('settings.labelWeight') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in providerForm.models" :key="m.model_id">
<td class="col-enabled">
<label class="toggle toggle--sm">
<input type="checkbox" v-model="m.enabled" />
<span class="toggle-slider"></span>
</label>
</td>
<td class="col-model">
<span class="model-id">{{ m.model_id }}</span>
</td>
<td class="col-ability">
<!-- 能力/模态标签(可编辑: × 删除; + 添加;手动改后标为手动) -->
<div class="model-tags">
<span v-for="md in m.modalities" :key="'md-' + md" class="tag tag-modality">{{ $t('settings.tagModality.' + md) }}</span>
<span v-for="cap in m.capabilities" :key="'cap-' + cap" class="tag tag-capability">{{ $t('settings.tagCapability.' + cap) }}</span>
<span v-if="m.probe_source" class="tag tag-source">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" @click.stop="removeModality(m, md)">×</span>
</span>
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" @click.stop="removeCapability(m, cap)">×</span>
</span>
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
<span class="tag-add" :title="$t('settings.addTagTitle')" @click.stop="startAddTag(m)"></span>
</div>
<!-- 添加能力内联菜单(展开;选未有的能力/模态即添加) -->
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
<span v-for="opt in addableTags(m)" :key="opt.kind + '-' + opt.key"
class="tag" :class="opt.kind === 'modality' ? 'tag-modality' : 'tag-capability'"
@click.stop="addTag(m, opt.key, opt.kind)">{{ opt.label }}</span>
<span v-if="addableTags(m).length === 0" class="add-tag-empty">{{ $t('settings.addTagNone') }}</span>
</div>
<div class="model-row-side">
<label class="model-weight-label">{{ $t('settings.labelWeight') }}</label>
</td>
<td class="col-weight">
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
@@ -151,7 +181,7 @@
import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig } from '@/api/types'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
// ============================================================
@@ -208,6 +238,35 @@ const providerForm = reactive({
savedSnapshot: '' as string,
})
/**
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
*/
const redundantTags = computed(() => {
const models = providerForm.models
if (models.length === 0) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
const common = (pick: (m: ModelConfig) => string[]) => {
const sets = models.map(m => new Set(pick(m)))
const first = sets[0]
const result = new Set<string>()
for (const v of first) {
if (sets.every(s => s.has(v))) result.add(v)
}
return result
}
return {
modalities: common(m => m.modalities),
capabilities: common(m => m.capabilities),
// 探测来源:全模型相同的值也冗余(如全 PresetTable)
sources: (() => {
const vals = models.map(m => m.probe_source).filter((v): v is ProbeSource => !!v)
if (vals.length < 2) return new Set<string>()
return new Set([...new Set(vals)].filter(v => vals.every(x => x === v)))
})(),
}
})
/**
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
* 触发场景:① 取消/关闭有改动的表单 → 二次确认放弃(L19);
@@ -231,10 +290,67 @@ function formBusinessSnapshot(): string {
model_id: m.model_id,
enabled: m.enabled,
weight: m.weight,
modalities: [...m.modalities],
capabilities: [...m.capabilities],
probe_source: m.probe_source,
})),
})
}
// ============================================================
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
// ============================================================
/** 当前展开「添加能力」菜单的模型名;null=无展开 */
const addingTagModel = ref<string | null>(null)
/** 可添加的模态(与 tagModality 对齐;text 是对话基础模态,允许删但一般不主动加) */
const ADDABLE_MODALITIES: Modality[] = ['text', 'vision', 'audio', 'video']
/** 可添加的能力 */
const ADDABLE_CAPABILITIES: Capability[] = ['tool_use', 'embedding', 'code_gen']
/** 某模型可添加的可选 tag(模态 + 能力,排除已选的) */
function addableTags(m: ModelConfig): { key: string; kind: 'modality' | 'capability'; label: string }[] {
const mods = ADDABLE_MODALITIES
.filter(x => !m.modalities.includes(x))
.map(x => ({ key: x, kind: 'modality' as const, label: t('settings.tagModality.' + x) }))
const caps = ADDABLE_CAPABILITIES
.filter(x => !m.capabilities.includes(x))
.map(x => ({ key: x, kind: 'capability' as const, label: t('settings.tagCapability.' + x) }))
return [...mods, ...caps]
}
/** 标记模型为「手动标注」(probe_source=user_set),下次探测不覆盖 */
function markManual(m: ModelConfig) {
m.probe_source = 'user_set'
addingTagModel.value = null
}
function removeModality(m: ModelConfig, md: Modality) {
m.modalities = m.modalities.filter(x => x !== md)
markManual(m)
}
function removeCapability(m: ModelConfig, cap: Capability) {
m.capabilities = m.capabilities.filter(x => x !== cap)
markManual(m)
}
function addTag(m: ModelConfig, key: string, kind: 'modality' | 'capability') {
if (kind === 'modality') {
const md = key as Modality
if (!m.modalities.includes(md)) m.modalities = [...m.modalities, md]
} else {
const cap = key as Capability
if (!m.capabilities.includes(cap)) m.capabilities = [...m.capabilities, cap]
}
markManual(m)
}
function startAddTag(m: ModelConfig) {
addingTagModel.value = addingTagModel.value === m.model_id ? null : m.model_id
}
/**
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
* 用于模板「取消」按钮 + saveProvider 成功后(无脏直接关)。
@@ -343,7 +459,18 @@ async function fetchModels() {
apiKey: providerForm.apiKey,
providerId: providerForm.editId || null,
})
providerForm.models = models.map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
providerForm.models = models.map(m => {
const old = providerForm.models.find(o => o.model_id === m.model_id)
const isManual = old?.probe_source === 'user_set'
return {
...m,
modalities: isManual ? [...old.modalities] : [...m.modalities],
capabilities: isManual ? [...old.capabilities] : [...m.capabilities],
probe_source: isManual ? 'user_set' : m.probe_source,
}
})
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
} catch (e) {
const msg = errMsg(e)
@@ -584,11 +711,11 @@ onMounted(loadProviders)
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
.form-field { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.form-label { flex: 0 0 72px; font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
/* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。
复用现有 --df-text-dim token(对齐 .empty-hint / .pool-weight-hint 风格)。 */
.form-field-hint { font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
横向布局下占满整行换到 label+input 下方。 */
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* P0-2: 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
@@ -608,9 +735,14 @@ onMounted(loadProviders)
word-break: break-word;
}
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
/* 模型列表区:占整行,恢复纵向布局(label 上 + 列表下),不受 .form-field 横向化影响 */
.form-field--full { flex-direction: column; align-items: stretch; gap: 4px; }
.form-field--full .form-label { flex: none; text-align: left; margin-bottom: 4px; }
.setting-input { flex: 1; min-width: 0; padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; }
.setting-input:focus { border-color: var(--df-accent); }
.setting-input--sm { min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
.form-field .setting-select { flex: 1; min-width: 0; }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
@@ -624,4 +756,91 @@ onMounted(loadProviders)
line-height: 1.5;
color: var(--df-warning);
}
/* ===== 模型列表(表格形式:表头 + 数据行) =====
原 model-list/model-row 等无任何样式定义(裸渲染),本批补齐并改为表格。
风格对齐 provider-card(浅底 / 圆角 / 0.5px 边框)。 */
.model-table {
width: 100%;
border-collapse: collapse;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
overflow: hidden;
font-size: 12px;
}
.model-table th,
.model-table td {
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
.model-table thead th {
background: var(--df-bg);
font-weight: 500;
color: var(--df-text-dim);
font-size: 11px;
border-bottom: 0.5px solid var(--df-border);
white-space: nowrap;
}
.model-table tbody tr { border-bottom: 0.5px solid var(--df-border); }
.model-table tbody tr:last-child { border-bottom: none; }
.model-table tbody tr:hover { background: var(--df-accent-bg); }
/* 列宽:启用窄、模型/能力弹性、权重窄 */
.model-table .col-enabled { width: 56px; text-align: center; }
.model-table .col-weight { width: 110px; }
.model-table .col-model { min-width: 140px; }
.col-enabled .toggle--sm { display: inline-block; }
.model-id {
font-size: 13px;
font-family: var(--df-font-mono);
color: var(--df-text);
font-weight: 500;
word-break: break-all;
}
/* 标签列:小圆角 chip,三类语义色区分 */
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
font-weight: 500;
white-space: nowrap;
}
.tag-modality { background: rgba(100,181,246,0.15); color: var(--df-info); }
.tag-capability { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.tag-source { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* 可编辑 tag:×删除按钮(悬停显红)+ 添加按钮 */
.tag-remove { margin-left: 3px; cursor: pointer; opacity: 0.6; font-weight: 700; }
.tag-remove:hover { opacity: 1; color: var(--df-danger); }
.tag-add {
cursor: pointer;
background: transparent;
border: 1px dashed var(--df-border);
color: var(--df-text-dim);
font-weight: 700;
}
.tag-add:hover { border-color: var(--df-accent); color: var(--df-accent); }
/* 添加能力内联菜单(点+展开):浅底容器包可选 tag */
.add-tag-menu {
margin-top: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
}
.add-tag-menu .tag { cursor: pointer; }
.add-tag-menu .tag:hover { filter: brightness(1.2); }
.add-tag-empty { font-size: 10px; color: var(--df-text-dim); padding: 2px 4px; }
/* 权重输入框:表格内固定窄宽 */
.model-table .setting-input--sm { width: 64px; }
.model-list-hint {
margin-top: 8px;
font-size: 11px;
color: var(--df-text-dim);
line-height: 1.5;
}
</style>
+4
View File
@@ -62,6 +62,10 @@
min-width: 80px;
}
.setting-number:focus { border-color: var(--df-accent); }
/* 隐藏原生数字加减箭头(与文本框视觉统一;注释声明无 spinner 但此前漏了实现) */
.setting-number::-webkit-outer-spin-button,
.setting-number::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.setting-number { -moz-appearance: textfield; appearance: textfield; }
/* text input:通用单行文本(目录/模型名/脚本名单) */
.setting-input {
+12
View File
@@ -67,6 +67,9 @@ export default {
fetchHintSaveFirst: 'Save the provider first, then fetch models',
labelWeight: 'Weight',
modelListTitle: 'Fetched models ({count})',
colEnabled: 'Enabled',
colModel: 'Model',
colAbility: 'Capability',
modelListHint: 'After toggling enabled / weight, click "Save" below to write them into the provider config; the next "Test connection" overwrites with fresh probe results',
saveModels: 'Save model config',
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
@@ -81,6 +84,13 @@ export default {
tagModality: { text: 'Text', vision: 'Vision', audio: 'Audio', video: 'Video' },
tagCapability: { tool_use: 'Tool use', embedding: 'Embedding', code_gen: 'Code' },
tagSource: { user_set: 'Manual', preset_table: 'Preset', heuristic: 'Heuristic', default: 'Default' },
// Tag tooltips: explain meaning & source reliability (manual = user-edited, highest priority)
tagTooltipModality: 'Modality: input types a model accepts (Vision = can read images). From preset table / name heuristic; editable',
tagTooltipCapability: 'Capability: what a model can do (Tool use = can call AI tools to act). From preset table / name heuristic; editable',
tagTooltipSource: 'Source: Preset = matched built-in table (reliable); Heuristic = guessed from name (approx); Manual = your override (priority)',
// In-table manual capability tagging (× to remove, to add)
addTagTitle: 'Add capability / modality',
addTagNone: 'All optional capabilities included',
// ===== Connection panel =====
panelConnection: '🔗 Connections',
@@ -92,10 +102,12 @@ export default {
labelHost: 'Host',
labelPort: 'Port',
labelUser: 'Username',
labelPassword: 'Password',
phConnName: 'flux_dev',
phHost: '127.0.0.1',
phPort: '3306',
phUser: 'root',
phPassword: '••••••••',
// ===== General settings panel =====
panelGeneral: '🎨 General',
+12
View File
@@ -66,6 +66,9 @@ export default {
fetchHintSaveFirst: '请先保存 Provider 后再拉取模型',
labelWeight: '权重',
modelListTitle: '已拉取模型({count} 个)',
colEnabled: '启用',
colModel: '模型',
colAbility: '能力',
modelListHint: '勾选 / 权重调整后,请点下方「保存」一并写入 Provider 配置;下次「测试连接」会以最新探测结果覆盖',
saveModels: '保存模型配置',
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
@@ -80,6 +83,13 @@ export default {
tagModality: { text: '文本', vision: '视觉', audio: '音频', video: '视频' },
tagCapability: { tool_use: '工具调用', embedding: '向量', code_gen: '代码' },
tagSource: { user_set: '手动', preset_table: '预设', heuristic: '推断', default: '默认' },
// 标签 tooltip:说明含义与来源可信度(手动标注 = 用户改过,优先级最高)
tagTooltipModality: '模态:模型能接收的输入类型(视觉=可看图)。来自预设表/按名推断,可手动修改',
tagTooltipCapability: '能力:模型能做什么(工具调用=可调用 AI 工具执行操作)。来自预设表/按名推断,可手动修改',
tagTooltipSource: '来源:预设=命中内置模型表(较可靠);推断=按模型名猜测(仅供参考);手动=你自定义(优先)',
// 表格内手动标注能力(点×删、点+加)
addTagTitle: '添加能力/模态',
addTagNone: '已含全部可选能力',
// ===== 连接管理面板 =====
panelConnection: '🔗 连接管理',
@@ -91,10 +101,12 @@ export default {
labelHost: 'Host',
labelPort: 'Port',
labelUser: '用户名',
labelPassword: '密码',
phConnName: 'flux_dev',
phHost: '127.0.0.1',
phPort: '3306',
phUser: 'root',
phPassword: '••••••••',
// ===== 通用设置面板 =====
panelGeneral: '🎨 通用设置',