优化: 前端u-agent令牌对齐与组件拆分续批

- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-18 23:15:57 +08:00
parent 302c4bcf52
commit 25ec9cf69a
62 changed files with 3475 additions and 2006 deletions
+5 -9
View File
@@ -91,8 +91,8 @@
<div class="update-progress-text">{{ progressText }}</div>
</div>
<!-- 错误提示 -->
<div v-if="installError" class="update-error">{{ installError }}</div>
<!-- 错误提示:视觉走全局 .df-note--danger -->
<div v-if="installError" class="df-note df-note--danger update-error">{{ installError }}</div>
<div class="update-card-actions">
<button
@@ -373,11 +373,12 @@ async function onIntervalChange() {
.update-progress {
margin-bottom: 12px;
}
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
.update-progress-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
background: var(--df-neutral-bg);
overflow: hidden;
}
.update-progress-fill {
@@ -391,14 +392,9 @@ async function onIntervalChange() {
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
}
/* 安装错误提示 */
/* 安装错误提示:底色/边/圆角/字号走全局 .df-note--danger(token 直引无回退),此处仅留排版 */
.update-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5;
word-break: break-all;
}
+7 -12
View File
@@ -86,11 +86,12 @@
</div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
<Transition name="confirm">
<!-- 按钮行/过渡消费 global.css .df-confirm-actions + df-confirm(2026-10-18 整洁波并轨) -->
<Transition name="df-confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<div class="df-confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div>
@@ -252,8 +253,9 @@ function onApprovalTimeoutChange() {
.setting-select/.setting-textarea + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control)。本 Section 仅保留 all 二次确认弹层特有 scoped 样式。
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
F-#97 all 二次确认弹层:mask/box/msg 本地特有样式;actions 按钮行与过渡已并轨
global.css .df-confirm-actions + df-confirm 过渡(2026-10-18 整洁波双轨合一)。
此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.data-dir-path {
flex: 1;
min-width: 0;
@@ -284,8 +286,6 @@ function onApprovalTimeoutChange() {
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 换行到整行下方。
┌───────────────────────────────┐
@@ -326,12 +326,7 @@ function onApprovalTimeoutChange() {
padding: 20px;
min-width: 280px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
box-shadow: var(--df-shadow-md);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>
+8 -10
View File
@@ -87,12 +87,13 @@
</section>
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行) -->
<Transition name="confirm">
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行;
按钮行/过渡消费 global.css .df-confirm-actions + df-confirm,2026-10-18 整洁波并轨) -->
<Transition name="df-confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<div class="df-confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div>
@@ -296,8 +297,10 @@ onMounted(load)
.script-safety-desc {
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
}
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
.script-safety-input {
min-width: 280px;
width: 280px;
max-width: 100%;
font-family: var(--df-font-mono, monospace);
}
.script-safety-note {
@@ -321,12 +324,7 @@ onMounted(load)
padding: 20px;
min-width: 280px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
box-shadow: var(--df-shadow-md);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>
+7 -10
View File
@@ -263,13 +263,10 @@ onMounted(loadConnections)
<style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/setting-input
已迁移到 settings.css 全局。
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列/
btn-link 及 form-field 内输入控件布局覆盖等。 */
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列
及 form-field 内输入控件布局覆盖等。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* 链接按钮 .btn-link/.btn-link-danger 已并轨 global.css(样式大重构去重) */
/* ===== 标题组(panel-header 内 标题+badge 一组,与右侧按钮 space-between) ===== */
.panel-title-group { display: flex; align-items: center; gap: 8px; }
@@ -307,10 +304,10 @@ onMounted(loadConnections)
border-radius: var(--df-radius-sm);
font-weight: 500;
}
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
.type-mysql { background: var(--df-info-bg); color: var(--df-info); }
.type-ssh { background: var(--df-accent-soft); color: var(--df-accent); }
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); }
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
.type-redis { background: var(--df-danger-bg); color: var(--df-danger); }
.type-mongo { background: var(--df-success-bg); color: var(--df-success); }
.conn-host, .conn-user {
font-size: 12px;
@@ -318,7 +315,7 @@ onMounted(loadConnections)
font-family: var(--df-font-mono);
}
.conn-user {
background: rgba(90,99,128,0.2);
background: var(--df-neutral-bg);
padding: 1px 6px;
border-radius: var(--df-radius-sm);
}
+4 -2
View File
@@ -142,9 +142,11 @@ async function save() {
<style scoped>
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.feishu-settings { display: flex; flex-direction: column; gap: 4px; }
.feishu-input { width: 280px; }
/* 定宽输入补 max-width,窄窗收缩防横向溢出 */
.feishu-input { width: 280px; max-width: 100%; }
.feishu-whitelist {
width: 320px;
max-width: 100%;
min-height: 64px;
resize: vertical;
font-family: var(--df-font-mono, monospace);
@@ -153,7 +155,7 @@ async function save() {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.5;
color: var(--df-text-tertiary, #888);
color: var(--df-text-dim);
}
.actions { display: flex; gap: 8px; margin-top: 8px; }
</style>
+1 -1
View File
@@ -38,7 +38,7 @@
</select>
</SettingRow>
<SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px; max-width:100%" />
</SettingRow>
</div>
</section>
+35 -409
View File
@@ -5,55 +5,9 @@
<h2>{{ $t('settings.panelAiProvider') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div>
<div class="provider-list" v-if="aiProviders.length > 0">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in aiProviders" :key="p.id">
<div class="provider-top">
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
<div class="detail-row detail-row--pool">
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
<div class="detail-value detail-value--control">
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
<div class="detail-value detail-value--control">
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
:value="p.weight ?? 50"
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
<span class="pool-weight-hint">0-100</span>
</div>
</div>
</div>
</div>
</div>
<!-- 卡片列表 + 负载均衡池控件拆至 provider-panel/provider-list.vue(C4c) -->
<ProviderList v-if="aiProviders.length > 0" ref="providerListRef" :providers="aiProviders"
@edit="openProviderForm" @delete="deleteProvider" @set-default="setDefaultProvider" @toast="relayToast" />
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
</section>
@@ -127,8 +81,8 @@
</div>
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
用户看到错误同时表单保持编辑态,改完点保存即可重试。 -->
<div v-if="providerForm.saveError" class="save-error-banner">
用户看到错误同时表单保持编辑态,改完点保存即可重试。视觉走全局 .df-note--danger。 -->
<div v-if="providerForm.saveError" class="df-note df-note--danger">
<pre class="save-error-msg">{{ providerForm.saveError }}</pre>
</div>
<div class="form-actions">
@@ -140,59 +94,10 @@
</button>
</div>
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ -->
<!-- ═══ 拉取到的模型列表(F-01 阶段6;表格+标签标注拆至 provider-panel/model-table.vue)═══ -->
<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>
<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" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="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" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="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" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="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>
<ModelTable :models="providerForm.models" />
<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>
@@ -205,11 +110,17 @@
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
import type { AiProviderConfig, ModelConfig } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
import ProviderList from './provider-panel/provider-list.vue'
import ModelTable from './provider-panel/model-table.vue'
// ============================================================
// AI 提供商域 — 真实后端 CRUD + c 负载均衡池(enabled / weight)
// C4c 拆分:卡片列表+池控件 → provider-panel/provider-list.vue;
// 模型表格+能力标签标注 → provider-panel/model-table.vue。
// 本件保留:表单状态机(校验红框/温度与权重钳制/快照脏判)、保存/测试连接/预设回填、
// 列表 CRUD 与 loadProviders 闭环、对壳 defineExpose 契约。
// ============================================================
const { t } = useI18n()
@@ -286,37 +197,7 @@ const providerForm = reactive({
savedSnapshot: '' as string,
})
/**
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
*/
const redundantTags = computed(() => {
const models = providerForm.models
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
if (models.length < 2) 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)))
})(),
}
})
// (冗余标签判定 redundantTags 随模型表格拆至 provider-panel/model-table.vue)
/**
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
@@ -349,59 +230,7 @@ function formBusinessSnapshot(): string {
})
}
// ============================================================
// 模型能力手动标注(表格内编辑 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
}
// (模型能力手动标注 tag 编辑块随模型表格拆至 provider-panel/model-table.vue)
/**
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
@@ -677,64 +506,15 @@ async function setDefaultProvider(id: string) {
}
}
// ============================================================
// c: 负载均衡池可编辑层(enabled / weight)
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
// 重建 per_provider caps,下条消息即按新配置 acquire。
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
// ============================================================
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
// (负载均衡池 enabled/weight 即时 IPC 块随卡片列表拆至 provider-panel/provider-list.vue)
/**
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
* 乐观更新本地数组,失败 revert + toast。
*/
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled
p.enabled = enabled
try {
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
/** 子件 toast 上抛中继(池控件 IPC 结果反馈) */
function relayToast(msg: string, type: 'error' | 'warning' | 'info' | 'success') {
emit('toast', msg, type)
}
/**
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
*/
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
const prevTimer = _poolTimers.get(p.id)
if (prevTimer) clearTimeout(prevTimer)
_poolTimers.set(p.id, setTimeout(async () => {
_poolTimers.delete(p.id)
try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}, 300))
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
/** 卡片列表子件引用(池 debounce 计时器下沉子件,卸载清理经此委托) */
const providerListRef = useTemplateRef<InstanceType<typeof ProviderList>>('providerListRef')
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
@@ -744,7 +524,7 @@ defineExpose({
loadProviders,
isFormDirty,
closeProviderForm,
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
clearPoolTimer() { providerListRef.value?.clearPoolTimer() },
})
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
@@ -754,92 +534,24 @@ onMounted(loadProviders)
<style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局。
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/
pool-badge/detail 系列/form 系列/btn-link/toggle--sm 及输入控件布局覆盖等。
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局,btn-link 已并轨 global.css。
C4c 拆分:provider 卡片/池控件样式随 provider-list.vue、模型表格/标签样式随
model-table.vue 下沉子件;本 scoped 仅保留表单区样式(form 系列/红框/错误横幅/
模型列表区容器与提示行)。.setting-input--sm/.toggle--sm 为两子件共用,各持一份。
注:注释列举类名时勿写星斜杠序列(会提前闭合 CSS 注释)。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* ===== Provider 卡片 ===== */
.provider-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.provider-info { display: flex; align-items: center; gap: 8px; }
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.default-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100,255,218,0.15);
color: var(--df-success);
font-weight: 500;
}
.provider-actions { display: flex; gap: 4px; }
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
.detail-label {
font-size: 12px;
color: var(--df-text-dim);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
.provider-card--disabled { opacity: 0.6; }
.pool-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(255,107,107,0.15);
color: var(--df-danger);
font-weight: 500;
}
/* 负载均衡池行(toggle / weight 控件) */
.detail-row--pool { align-items: center; }
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
/* 表单(本面板特有输入控件样式;.setting-select 走全局)。
--form-label-w 为标签列宽单一来源:hint 缩进用 calc(label 列宽+行 gap) 对齐 input 位,
免手算魔法数。 */
.form-grid { --form-label-w: 72px; display: flex; flex-direction: column; gap: 14px; }
.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; }
.form-label { flex: 0 0 var(--form-label-w); font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
/* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。
横向布局下占满整行换到 label+input 下方。 */
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
横向布局下占满整行换到 label+input 下方,缩进与 input 左缘结构对齐。 */
.form-field-hint { flex-basis: 100%; margin-left: calc(var(--form-label-w) + 10px); font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
.save-error-banner {
background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm);
padding: 10px 12px;
}
/* 保存失败持久错误横幅:视觉(底色/边/圆角/内距)走全局 .df-note--danger,此处仅留内部排版 */
.save-error-msg {
margin: 0;
font-family: var(--df-font-sans);
@@ -860,12 +572,6 @@ onMounted(loadProviders)
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.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; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
.model-list-unsaved-hint {
@@ -874,86 +580,6 @@ 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;
+4 -2
View File
@@ -115,10 +115,12 @@ async function save() {
<style scoped>
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.uclaw-settings { display: flex; flex-direction: column; gap: 4px; }
.uclaw-input { width: 320px; font-family: var(--df-font-mono, monospace); }
/* 定宽输入补 max-width,窄窗收缩防横向溢出 */
.uclaw-input { width: 320px; max-width: 100%; font-family: var(--df-font-mono, monospace); }
/* 原 var(--df-text-tertiary) 为幽灵 token(全 styles 未定义,渲染成继承色),改用已定义的弱化色 */
.uclaw-hint {
font-size: 11px;
color: var(--df-text-tertiary);
color: var(--df-text-dim);
line-height: 1.6;
margin-top: 4px;
white-space: pre-line;
+2 -1
View File
@@ -145,7 +145,8 @@ function onPollSecsChange(raw: string) {
<style scoped>
/* panel/panel-header/toggle/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.unattended-settings { display: flex; flex-direction: column; gap: 4px; }
.unattended-input { width: 280px; }
/* 定宽输入补 max-width,窄窗收缩防横向溢出(--num 变体继承同款收缩) */
.unattended-input { width: 280px; max-width: 100%; }
.unattended-input--num { width: 110px; }
.unattended-radios { display: flex; gap: 16px; flex-wrap: wrap; }
.unattended-radio {
@@ -0,0 +1,249 @@
<template>
<!-- 拉取到的模型列表表格(F-01 阶段6)。自 ProviderPanel 拆出(C4c 纯搬迁):
表格渲染 + 能力/模态标签手动标注(改动即标 UserSet)自治在本件。
models 为主文件 providerForm.models 的引用,行内 v-model/标签增删直接改
item 字段(共享响应式对象),脏态由主文件 isFormDirty 统一比对。 -->
<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 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" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="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" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="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" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="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>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
// ============================================================
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
// —— 原 ProviderPanel 整块搬迁(C4c 纯搬迁,共享函数不新造)。
// ============================================================
const { t } = useI18n()
const props = defineProps<{
/** 主文件 providerForm.models 引用(行内直改 item 字段,保存闭环留主文件 saveProvider) */
models: ModelConfig[]
}>()
/**
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
*/
const redundantTags = computed(() => {
const models = props.models
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
if (models.length < 2) 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)))
})(),
}
})
/** 当前展开「添加能力」菜单的模型名;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
}
</script>
<style scoped>
/* 自 ProviderPanel scoped 拆出(C4c 纯搬迁):模型表格/标签样式随区块走;
.setting-input--sm 与 .toggle--sm 为本件与 provider-list 子件共用,两处各持一份
(scoped 不跨组件,全局化去重留待后续统一处理)。 */
/* ===== 模型列表(表格形式:表头 + 数据行) =====
原 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: var(--df-info-bg); color: var(--df-info); }
.tag-capability { background: var(--df-accent-soft); color: var(--df-accent); }
.tag-source { background: var(--df-neutral-bg); 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; }
/* 行内窄输入(权重等):定高 26px 档(--df-h-input),与 .setting-input 主体高度齐平 */
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; height: var(--df-h-input); padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(表格启用开关用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
</style>
@@ -0,0 +1,213 @@
<template>
<!-- provider 卡片列表。自 ProviderPanel 拆出(C4c 纯搬迁):
卡片渲染 + 负载均衡池控件(enabled/weight 即时 IPC)自治在本件,
增删改/设默认经 emit 上抛主文件(共享 loadProviders 闭环)。 -->
<div class="provider-list">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in providers" :key="p.id">
<div class="provider-top">
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="emit('set-default', p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="emit('edit', p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="emit('delete', p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
<div class="detail-row detail-row--pool">
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
<div class="detail-value detail-value--control">
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
<div class="detail-value detail-value--control">
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
:value="p.weight ?? 50"
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
<span class="pool-weight-hint">0-100</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig } from '@/api/types'
// ============================================================
// c: 负载均衡池可编辑层(enabled / weight)——原 ProviderPanel 整块搬迁
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
// 重建 per_provider caps,下条消息即按新配置 acquire。
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
// ============================================================
const { t } = useI18n()
defineProps<{
/** 主文件 aiProviders 引用;池控件直接改 item 字段(共享响应式对象,乐观更新),列表整体替换仍由主文件驱动 */
providers: AiProviderConfig[]
}>()
const emit = defineEmits<{
(e: 'edit', p: AiProviderConfig): void
(e: 'delete', id: string): void
(e: 'set-default', id: string): void
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
}>()
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
* 乐观更新本地数组,失败 revert + toast。
*/
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled
p.enabled = enabled
try {
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}
/**
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
*/
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
const prevTimer = _poolTimers.get(p.id)
if (prevTimer) clearTimeout(prevTimer)
_poolTimers.set(p.id, setTimeout(async () => {
_poolTimers.delete(p.id)
try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}, 300))
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
/** 主文件 defineExpose.clearPoolTimer 委托到此处(壳卸载清 timer 的对外契约不变)。 */
defineExpose({
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
})
</script>
<style scoped>
/* 自 ProviderPanel scoped 拆出(C4c 纯搬迁):provider 卡片/池控件样式随区块走;
.setting-input--sm 与 .toggle--sm 为本件与 model-table 子件共用,两处各持一份
(scoped 不跨组件,全局化去重留待后续统一处理)。 */
/* ===== Provider 卡片 ===== */
.provider-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.provider-info { display: flex; align-items: center; gap: 8px; }
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.default-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: var(--df-success-bg);
color: var(--df-success);
font-weight: 500;
}
.provider-actions { display: flex; gap: 4px; }
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
.detail-label {
font-size: 12px;
color: var(--df-text-dim);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
.provider-card--disabled { opacity: 0.6; }
.pool-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: var(--df-danger-bg);
color: var(--df-danger);
font-weight: 500;
}
/* 负载均衡池行(toggle / weight 控件) */
.detail-row--pool { align-items: center; }
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
/* 行内窄输入(权重等):定高 26px 档(--df-h-input),与 .setting-input 主体高度齐平 */
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; height: var(--df-h-input); padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
</style>
+2 -9
View File
@@ -120,15 +120,8 @@
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
/* ===== 按钮(原 Settings.vue,列表型 Panel 共用) ===== */
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: background 0.15s, color 0.15s; }
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
/* ===== 按钮 =====
样式大重构 R0(2026-10-18):.btn 族副本已删除,并轨 global.css 唯一基类(高度档 --df-h-btn)。 */
/* ===== 空提示(列表型 Panel 共用) ===== */
.empty-hint { color: var(--df-text-dim); font-size: 13px; padding: 16px 0; text-align: center; }