Files
DevFlow/src/components/settings/provider-panel/provider-list.vue
T
lxy eec5978fa7 修复: 上下文管理第二批(E5注入产物/E13超时收敛/E2降级透出)
- E5:[补充指令]与[系统恢复]同治三处——载入滤出/恢复回放剔除/
  首条任务消息排除(434c8c40占位实证),is_guide_artifact统一判据
- E13:同工具超时连败≥3跨签名聚合,执行前软收敛警告(治换档位调参
  逃逸,434c8c40 run_command 8连败实证)
- E2:压缩失败走关键词摘要兜底时新发AiContextDegraded,前端toast
  「上下文已降级」不再静默失忆(76730ce8 seq166实证)
- vite watch忽略target/(cargo编译写.o触发EBUSY杀dev进程)
- provider-list模板根div缺闭合补齐(拆分半成品断链)

验收:tool_failure 5/keyword_fallback 3/recovery 6 passed;vue-tsc EXIT 0
2026-10-19 00:25:51 +08:00

215 lines
9.3 KiB
Vue

<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>
</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>