优化: Settings 前端(Provider 模型表格化手动标注 + 布局重构 + 连接密码字段)
This commit is contained in:
@@ -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">
|
||||
<label class="toggle toggle--sm">
|
||||
<input type="checkbox" v-model="m.enabled" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span class="model-id">{{ m.model_id }}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-row-side">
|
||||
<label class="model-weight-label">{{ $t('settings.labelWeight') }}</label>
|
||||
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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" 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>
|
||||
</td>
|
||||
<td class="col-weight">
|
||||
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user