Files
ai-kefu-admin/docs/superpowers/plans/2026-10-14-config-center-ux-visual.md
T

1216 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 配置中心易用性与视觉优化 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 按spec(docs/superpowers/specs/2026-10-14-config-center-ux-visual-design.md)将配置中心重组为分层双模式并统一视觉基线——体验度(结构/引导/保护)与美观度(排版/层级/一致性)双线优化,纯前端零后端改动。
**Architecture:** admin/ SPA 内改造 config/center:新增共享基线组件(ConfigCard 卡壳 / SecretField 密钥四件套 / KeyValueEditor 键值行编辑),WallPane 拆场景卡+高级折叠,JsonGroupPane 退役为三份表单 pane;全部视图逻辑沉淀 model.ts 纯函数(vitest 直测、无组件挂载测试——项目铁律);PUT 整组替换、`__SET__` 哨兵、试金石预演、未接管键透传四契约不动。
**Tech Stack:** Vue 3.4 + TypeScript + Element Plus(auto-import:ref/computed/ElMessage 等无需 import)+ vitest;样式用 `var(--art-*)`/`var(--el-*)` 语义色(暗色模式两态验收);图标用 ArtSvgIcon(模板已有,禁 emoji)。
**执行环境注意(Windows):**
- 所有命令在仓库根 `D:\refactor\all-kefu` 下执行;前端命令先 `cd admin`
- 单测:`cd admin && pnpm exec vitest run src/views/config/center/__tests__/model.test.ts`(全量:`pnpm test`)
- 构建:`cd admin && pnpm build`(vue-tsc --noEmit && vite build);build 后若 `components.d.ts`/`auto-imports.d.ts` 出现行尾漂移:`git diff --ignore-cr-at-eol` 为空则 `git checkout -- admin/src/types/import/components.d.ts admin/src/types/import/auto-imports.d.ts` 还原(入库版优先纪律)
- **新组件须显式 import**:view 内新组件(ConfigCard/SecretField/KeyValueEditor/ContactFormPane/EngineFormPane/MiscFormPane/wall/ScenarioCard/wall/WallAdvanced)不被 unplugin-vue-components 自动注册(它只扫 src/components)——消费方 SFC 必须 `import ConfigCard from './ConfigCard.vue'` 等(与 index.vue 显式 import 既有 panes 同款);漏 import 构建不报错但渲染空白,dev 走查必看
- **ElMessageBox 非 auto-import**(auto-imports 只有 ElMessage):用到 `ElMessageBox.confirm` 的文件顶部 `import { ElMessageBox } from 'element-plus'`(reset.ts 同款)
- 本地验收环境:Docker 容器 all-kefu-app-1(8018,`/admin/`)跑的是**构建产物**——前端改动本地验收用 dev server:`cd admin && pnpm dev`(默认 3006,代理已配好);超管 admin / 密码 12345678(本机测试值);登录滑块为纯前端校验可直接拖
- 每个任务收尾 `git status --short` 应只剩本任务相关文件;截图等临时产物放 `.superpowers/`(已 gitignore),收尾删净
---
## Batch 1:视觉基线 + SecretField + 导航分层 + brand/creds(✅ 2026-10-14 完成——commits 0a41352/6881ccc/2e00db1+46dfbef/712c5f9/06e28e4/680c4ba;全任务过双审;视觉走查通过;遗留打磨项记 Task 15:色列宽度平衡/预览卡文案去重/头像提示样式)
### Task 1: model.ts 新增 secretFieldState 纯函数(TDD)
**Files:**
- Modify: `admin/src/views/config/center/model.ts`(import 行 + 新增区块)
- Test: `admin/src/views/config/center/__tests__/model.test.ts`(追加)
- [ ] **Step 1: 写失败测试**——在 model.test.ts 追加:
```ts
import { secretFieldState } from '../model'
describe('secretFieldState(密钥四件套展示态)', () => {
it('未配置:input 空 + 无掩码', () => {
const s = secretFieldState('', '', false)
expect(s).toMatchObject({ badge: 'unset', badgeType: 'info', badgeLabel: '未配置' })
expect(s.placeholder).toContain('未设置')
})
it('已配置:掩码哨兵且未动', () => {
const s = secretFieldState('', '__SET__', false)
expect(s).toMatchObject({ badge: 'configured', badgeType: 'success', badgeLabel: '已配置' })
expect(s.placeholder).toContain('留空保持不变')
})
it('pending-new:输入了新值(优先级最高)', () => {
const s = secretFieldState('new-secret', '__SET__', true)
expect(s).toMatchObject({ badge: 'pending-new', badgeType: 'warning', badgeLabel: '已输入新值 · 未保存' })
})
it('pending-clear:清除了已配置值', () => {
const s = secretFieldState('', '__SET__', true)
expect(s).toMatchObject({ badge: 'pending-clear', badgeType: 'warning', badgeLabel: '将清除 · 未保存' })
})
})
```
- [ ] **Step 2: 跑测试确认失败**——`cd admin && pnpm exec vitest run src/views/config/center/__tests__/model.test.ts`,预期 FAIL:secretFieldState 未导出
- [ ] **Step 3: 实现**——model.ts 顶部 import 行改为 `import { MASKED_SENTINEL, isMaskedSentinel, secretToSubmit } from '@/api/mask'`;在「凭据三组」区块(`normalizeAiCredsGroup` 之后)插入:
```ts
// ---- SecretField 展示态(视觉基线 §4.2;提交值仍走 secretToSubmit) -----------------
/** 密钥字段徽章四态:已配置 / 未配置 / 已输入新值(未保存) / 将清除(未保存) */
export type SecretFieldBadge = 'configured' | 'unset' | 'pending-new' | 'pending-clear'
export interface SecretFieldState {
badge: SecretFieldBadge
/** ElTag type 映射(configured=success / unset=info / pending-*=warning) */
badgeType: 'success' | 'info' | 'warning'
badgeLabel: string
placeholder: string
}
/** 输入值 + GET 掩码态 + 清除开关 → 徽章/占位文案(纯展示;优先级 新值 > 清除 > 已配置 > 未配置) */
export function secretFieldState(
input: string,
masked: string,
cleared: boolean
): SecretFieldState {
if (input !== '') {
return {
badge: 'pending-new', badgeType: 'warning', badgeLabel: '已输入新值 · 未保存', placeholder: ''
}
}
if (cleared) {
return {
badge: 'pending-clear', badgeType: 'warning', badgeLabel: '将清除 · 未保存', placeholder: ''
}
}
if (isMaskedSentinel(masked)) {
return {
badge: 'configured', badgeType: 'success', badgeLabel: '已配置',
placeholder: '已设置,输入新值可更换(留空保持不变)'
}
}
return { badge: 'unset', badgeType: 'info', badgeLabel: '未配置', placeholder: '未设置,输入新值' }
}
```
- [ ] **Step 4: 跑测试确认通过**——同 Step 2 命令,预期 4 个新用例 PASS
- [ ] **Step 5: Commit**——`git add admin/src/views/config/center/model.ts admin/src/views/config/center/__tests__/model.test.ts && git commit -m "feat(admin): secretFieldState 密钥四态展示纯函数"`
### Task 2: SecretField.vue 组件
**Files:**
- Create: `admin/src/views/config/center/SecretField.vue`
- [ ] **Step 1: 创建组件**(全量代码):
```vue
<!--
密钥字段四件套(视觉基线 §4.2):状态徽章(secretFieldState 四态)+
密码输入(show-password)+ 清除/恢复保持 + 清除警告文案。
提交值计算不在本组件——父层沿用 build*Payload(form, cleared ? '' : masked)
(mask.ts secretToSubmit 契约)。
-->
<template>
<div class="secret-field">
<div class="secret-row">
<ElInput
:model-value="modelValue"
type="password"
show-password
autocomplete="new-password"
:placeholder="state.placeholder"
class="secret-input"
@update:model-value="$emit('update:modelValue', $event)"
/>
<ElTag :type="state.badgeType" size="small" effect="light" class="secret-badge">
{{ state.badgeLabel }}
</ElTag>
<ElButton
v-if="masked === MASKED_SENTINEL"
size="small"
:type="cleared ? 'default' : 'danger'"
plain
@click="$emit('update:cleared', !cleared)"
>
{{ cleared ? '恢复保持' : '清除' }}
</ElButton>
</div>
<p v-if="state.badge === 'pending-clear'" class="field-warn">
保存后将清除已存密钥(空槽回退平台默认)
</p>
</div>
</template>
<script setup lang="ts">
import { MASKED_SENTINEL } from '@/api/mask'
import { secretFieldState } from './model'
defineOptions({ name: 'SecretField' })
const props = defineProps<{
/** 用户输入(空串 = 未输入) */
modelValue: string
/** GET 掩码态('__SET__' = 库中已有值;'' = 未设置) */
masked: string
/** 清除开关(true = 提交空串显式清空) */
cleared: boolean
}>()
defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'update:cleared', value: boolean): void
}>()
const state = computed(() => secretFieldState(props.modelValue, props.masked, props.cleared))
</script>
<style scoped lang="scss">
.secret-field {
width: 100%;
.secret-row {
display: flex;
align-items: center;
gap: 8px;
.secret-input {
flex: 1;
min-width: 0;
}
.secret-badge {
flex-shrink: 0;
}
}
.field-warn {
margin: 4px 0 0;
font-size: 12px;
color: var(--el-color-danger);
}
}
</style>
```
- [ ] **Step 2: 构建验证**——`cd admin && pnpm build`,预期零错误(vue-tsc 通过)
- [ ] **Step 3: Commit**——`git add admin/src/views/config/center/SecretField.vue && git commit -m "feat(admin): SecretField 密钥四件套组件"`
### Task 3: ConfigCard.vue 基线卡壳 + 共享样式
**Files:**
- Create: `admin/src/views/config/center/ConfigCard.vue`
- [ ] **Step 1: 创建组件**(全量代码):
```vue
<!--
视觉基线卡壳(spec §4):三行卡头(图标+任务名 → 一句话说明 → 组级信息条)
+ 底部操作条(左 回滚链接 / 右 备注选填+保存——均 slot)+ 底部危险行。
只管布局与样式,不含任何业务逻辑。
-->
<template>
<ElCard shadow="never" class="config-card">
<template #header>
<div class="cc-header">
<div class="cc-title-row">
<ArtSvgIcon v-if="icon" :icon="icon" class="cc-icon" />
<span class="cc-title">{{ title }}</span>
</div>
<p v-if="subtitle" class="cc-subtitle">{{ subtitle }}</p>
<div v-if="info || $slots.info" class="cc-info">
<slot name="info">
<ArtSvgIcon icon="solar:info-circle-line-duotone" class="cc-info-icon" />
<span>{{ info }}</span>
</slot>
</div>
</div>
</template>
<slot />
<div v-if="$slots['footer-left'] || $slots['footer-right']" class="cc-footer">
<div class="cc-footer-left">
<slot name="footer-left" />
</div>
<div class="cc-footer-right">
<slot name="footer-right" />
</div>
</div>
<div v-if="dangerLabel || $slots.danger" class="cc-danger">
<span class="cc-danger-text">{{ dangerLabel }}</span>
<slot name="danger" />
</div>
</ElCard>
</template>
<script setup lang="ts">
defineOptions({ name: 'ConfigCard' })
defineProps<{
/** ArtSvgIcon 图标名(如 solar:palette-round-line-duotone) */
icon?: string
/** 任务名(品牌与外观 / AI 话术 …) */
title: string
/** 一句话「这是干嘛的」 */
subtitle?: string
/** 组级信息条文案(整组保存 · 立即生效 · 可回滚;带 • 字段留空=默认) */
info?: string
/** 危险行文案(有值才渲染危险行;按钮走 #danger slot) */
dangerLabel?: string
}>()
</script>
<style scoped lang="scss">
.config-card {
:deep(.el-card__header) {
padding: 14px 20px 10px;
}
:deep(.el-card__body) {
padding: 14px 20px;
}
}
.cc-header {
.cc-title-row {
display: flex;
align-items: center;
gap: 8px;
.cc-icon {
font-size: 18px;
color: var(--art-primary, var(--el-color-primary));
}
.cc-title {
font-weight: 600;
font-size: 15px;
color: var(--el-text-color-primary);
}
}
.cc-subtitle {
margin: 2px 0 0;
font-size: 12px;
color: var(--art-gray-500, var(--el-text-color-secondary));
}
.cc-info {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 7px 12px;
border-radius: 6px;
background: var(--el-fill-color-lighter);
font-size: 12px;
line-height: 1.6;
color: var(--art-gray-500, var(--el-text-color-secondary));
.cc-info-icon {
flex-shrink: 0;
font-size: 14px;
color: var(--art-gray-400, var(--el-text-color-placeholder));
}
}
}
.cc-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--el-border-color-lighter);
.cc-footer-right {
display: flex;
align-items: center;
gap: 8px;
}
}
.cc-danger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 7px 12px;
border: 1px solid var(--el-color-danger-light-7);
border-radius: 6px;
background: var(--el-color-danger-light-9);
.cc-danger-text {
font-size: 12px;
color: var(--el-color-danger);
}
}
</style>
```
- [ ] **Step 2: 构建验证**——`cd admin && pnpm build`,零错误
- [ ] **Step 3: Commit**——`git add admin/src/views/config/center/ConfigCard.vue && git commit -m "feat(admin): ConfigCard 视觉基线卡壳"`
### Task 4: index.vue 导航分层
**Files:**
- Modify: `admin/src/views/config/center/index.vue:519-562`(GROUP_NAV/IMPLEMENTED_GROUPS/activeGroupLabel 区)、`index.vue:46-47`(页副标题)、`index.vue:87-95`(导航模板)、`index.vue:543`(JSON_GROUPS)
- [ ] **Step 1: 替换导航数据结构**——`GROUP_NAV`/`IMPLEMENTED_GROUPS` 区块改为:
```ts
/** 导航项:任务名 + 副标题(导航与卡头共用文案源) */
interface NavItem {
key: GroupKey
label: string
sub: string
}
/** 分层双模式(spec §3):常用 = 日常自助;高级 = 引擎级参数 */
const NAV_SECTIONS: Array<{ title: string; icon: string; items: NavItem[] }> = [
{
title: '常用配置',
icon: 'solar:star-line-duotone',
items: [
{ key: 'brand', label: '品牌与外观', sub: '聊天窗标题/颜色/头像/欢迎语' },
{ key: 'wall', label: 'AI 话术', sub: '问价格/要联系方式时 AI 怎么回' },
{ key: 'prompts', label: '公司资料', sub: 'AI 介绍你公司时依据的内容' },
{ key: 'contact', label: '联系方式', sub: '要 WhatsApp/邮箱的行为开关' },
{ key: 'creds', label: '凭据与通知', sub: '邮件/推送/AI 密钥 + 连通测试' }
]
},
{
title: '高级配置',
icon: 'solar:settings-minimalistic-line-duotone',
items: [
{ key: 'engine', label: '聊天限流', sub: '每 IP 发言/连接上限' },
{ key: 'misc', label: '国家区号映射', sub: '国名 → 区号补全规则' }
]
}
]
/** 版本历史:独立于两层之外的救援入口(spec §3) */
const VERSIONS_NAV: NavItem = {
key: 'versions',
label: '版本历史',
sub: '改坏了?这里一键回滚'
}
/** 全部导航项扁平表(activeGroupLabel / 查找用) */
const ALL_NAV_ITEMS: NavItem[] = [
...NAV_SECTIONS.flatMap((s) => s.items),
VERSIONS_NAV
]
```
`activeGroupLabel` 改为 `ALL_NAV_ITEMS.find((i) => i.key === activeGroup.value)?.label ?? ''`;删除 `IMPLEMENTED_GROUPS`。**GroupKey 类型重定义**(原 `typeof GROUP_NAV` 已删)——用显式联合字面量(**不可**写 `(typeof ALL_NAV_ITEMS)[number]['key']`:NavItem.key 标注为 GroupKey 会构成循环类型别名,tsc 报 TS2456/TS2502):
```ts
type GroupKey =
| 'brand' | 'wall' | 'prompts' | 'contact' | 'creds'
| 'engine' | 'misc' | 'versions'
```
- [ ] **Step 2: 重写导航模板**——`<ElCard class="nav-card">` 内的 ElMenu 区改为:
```html
<ElMenu :default-active="activeGroup" class="group-nav" @select="onGroupSelect">
<template v-for="section in NAV_SECTIONS" :key="section.title">
<div class="nav-section-title">
<ArtSvgIcon :icon="section.icon" class="nav-section-icon" />
{{ section.title }}
</div>
<ElMenuItem v-for="item in section.items" :key="item.key" :index="item.key">
<div class="nav-item-text">
<span class="nav-label">{{ item.label }}</span>
<span class="nav-sub">{{ item.sub }}</span>
</div>
</ElMenuItem>
</template>
<div class="nav-section-title nav-versions-title">
<ArtSvgIcon icon="solar:history-line-duotone" class="nav-section-icon" />
救援
</div>
<ElMenuItem :index="VERSIONS_NAV.key">
<div class="nav-item-text">
<span class="nav-label">{{ VERSIONS_NAV.label }}</span>
<span class="nav-sub">{{ VERSIONS_NAV.sub }}</span>
</div>
</ElMenuItem>
</ElMenu>
```
同步新增 scoped 样式(.nav-section-title 小节标题 12px 大写字距、.nav-item-text 两行布局 .nav-sub 11px 灰);页副标题(index.vue:47)改为 `按左侧分组编辑——保存后立即生效并产生新版本,历史可随时回滚`。
- [ ] **Step 3: 验证**——`cd admin && pnpm build` 零错误;`pnpm dev` 打开配置中心,导航呈两层 + 版本独立、无「已实装」徽标、各组 pane 正常切换(暂用旧 pane 不影响)
- [ ] **Step 4: Commit**——`git add admin/src/views/config/center/index.vue && git commit -m "feat(admin): 配置中心导航分层双模式(常用/高级/版本独立)"`
### Task 5: CredsPane 套基线 + SecretField 接入
**Files:**
- Modify: `admin/src/views/config/center/CredsPane.vue`(三卡模板重排,脚本层函数全部保留)
- [ ] **Step 1: 邮件卡改造**——`<ElCard>` 换 `<ConfigCard icon="solar:letter-linear" title="邮件通知" subtitle="客户留下线索后,系统用这组配置发通知邮件" info="整组保存 · 立即生效 · 产生新版本可回滚;带 • 的字段留空 = 沿用平台默认" danger-label="恢复出厂:把邮件配置重置为初始档案值">`;表单区改双列 grid(`<div class="form-grid">` 包 ElFormItem:服务器/端口同排、账号/发件人同排、密码独占、通知邮箱独占——样式 `.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 16px; .span-2 { grid-column:span 2 } }`);SMTP 密码整块换 `<SecretField v-model="emailForm.smtpPassword" :masked="emailPasswordMasked" :cleared="emailPasswordCleared" @update:cleared="emailPasswordCleared = $event" />`(**masked 传原始掩码值**——SecretField 内部 v-if 按 masked===哨兵 渲染清除按钮,传 `cleared ? '' : masked` 会让按钮在清除态消失、恢复保持不可达;徽章优先级 secretFieldState 已处理 cleared>masked)。原 secret-row/清除按钮/field-warn 模板删除。**提交路径不动**:onSaveEmail 里 buildEmailCredsPayload(form, emailPasswordCleared ? '' : emailPasswordMasked) 维持现状(脚本层三元,与模板 prop 无关);底部操作条:
```html
<template #footer-left>
<ElButton link type="primary" size="small" @click="$emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput v-model="emailComment" maxlength="200" placeholder="备注(选填)" class="comment-input" />
<ElButton type="primary" :loading="emailSaving" @click="onSaveEmail">保存</ElButton>
</template>
<template #danger>
<ElButton size="small" type="danger" plain :loading="resettingEmail" @click="onResetGroup('email')">
重置出厂…
</ElButton>
</template>
```
emits 声明追加 `(e: 'navigate', group: string): void`。
- [ ] **Step 2: 推送/AI 两卡同款改造**——推送 ConfigCard `icon="solar:upload-square-line-duotone" title="线索推送" subtitle="新线索实时推送到你的业务系统(UniService)"`,pushApiKey 换 SecretField;AI 卡 `icon="solar:cpu-bolt-line-duotone" title="AI 服务" subtitle="对话引擎调用的大模型服务凭据"`,aiApiKey 换 SecretField;各自 footer/danger 同款
- [ ] **Step 3: 连通测试小节归置**——两处测试区(`ElDivider` 连通性验证)保持卡内底部,邮件测试按钮文案改「发送测试邮件(真发一封)」、hint 首词「后端用」改「系统用」;确认 429 响应走既有 error 内联路径(后端 detail 已是白话「测试端点调用过于频繁(每租户 15 分钟 5 次)」,无需前端翻译——onTestEmail 的 catch 已内联展示,验证即可)
- [ ] **Step 4: 验证 + Commit**——`pnpm build` 零错误;`pnpm dev` 走查:密码徽章四态(未配置/已配置/输入新值/清除)、清除后保存提交空串行为不变(既有 vitest 契约不回归);`git commit -m "feat(admin): 凭据页套视觉基线+SecretField 四件套"`
### Task 6: 品牌卡套基线
**Files:**
- Modify: `admin/src/views/config/center/index.vue`(brand pane 模板区,约 99-260 行;另两处小修)
- Modify: `admin/src/views/config/center/CredsPane.vue`(三处 info 文案去「带 • 的」前缀)
- [ ] **Step 1: 表单卡换 ConfigCard**——`icon="solar:palette-round-line-duotone" title="品牌与外观" subtitle="客户看到的聊天窗口长什么样" info="整组保存 · 立即生效;图片与链接留空 = 不展示"`;短字段(标题/主色/WhatsApp/邮箱/占位提示)双列 grid、话题标签与隐私链接独占;footer/danger 同 Task 5 模式(footer-left 回滚、footer-right 备注+保存、danger 重置出厂)。brand 是 index.vue 内联块——回滚按钮直接 `@click="onGroupSelect('versions')"`(无需 emit)
- [ ] **Step 2: 预览与嵌入代码卡理顺**——预览卡与嵌入代码卡紧随表单卡,预览卡头部加副标题「本地简化预览(真机效果以嵌入站点为准)」
- [ ] **Step 2b: (Task 5 评审折入)navigate 接线 + 文案清理 + 死分支剪除**——① index.vue 的 `<CredsPane>` 挂载处补 `@navigate="onGroupSelect"`;② CredsPane 三卡 info 文案的「带 • 的字段留空 = 沿用平台默认」改为「字段留空 = 沿用平台默认」(• 标记约定未实装到 label,删引用防误导);③ 剪除 pane 条件链尾部不可达的 v-else 占位卡(「后续任务实装」——8 组全实装后为死分支)
- [ ] **Step 3: 验证 + Commit**——`pnpm build` + dev 走查(改标题/颜色预览即时反映;凭据页点「出问题?回滚 →」能跳版本历史);`git commit -m "feat(admin): 品牌卡套视觉基线(Batch 1 收口)"`
---
## Batch 2:wall 重组(场景卡 + 高级折叠)(✅ 2026-10-14 完成——commits 2e6811a+36fe534/ab0cf9f/0553ccc/12b8dab;全任务过双审;视觉+交互走查通过:场景卡语言切换/试金石预演实测;遗留:试金石异步窗口内编辑被旧 payload 覆盖(既有性状,可选加固))
### Task 7: model.ts wall 辅助纯函数(TDD)
**Files:**
- Modify: `admin/src/views/config/center/model.ts`(wall 区块追加)
- Test: `admin/src/views/config/center/__tests__/model.test.ts`(追加)
- [ ] **Step 1: 写失败测试**:
```ts
import { poolLanguages, templateVariables, removedTemplateVariables, collectRemovedWallVariables, WALL_SCENARIOS, SCENARIO_WHEN_LABELS } from '../model'
describe('poolLanguages(场景卡语言 tabs)', () => {
it('多池语言并集,必留语言在前、附加语言按序', () => {
expect(poolLanguages({
deflections: { en: ['a'], zh: ['b'], es: ['c'] },
deflections_contact: { en: ['a'], pt: ['d'] }
})).toEqual(['en', 'zh', 'es', 'pt'])
})
it('空池 → 空数组', () => {
expect(poolLanguages({ deflections: {}, deflections_contact: {} })).toEqual([])
})
})
describe('模板变量保护', () => {
it('提取 {var} 变量', () => {
expect(templateVariables('到 {intl} 联系您,{shown} 已留')).toEqual(['intl', 'shown'])
})
it('删除快照出现过的变量被检出', () => {
expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl'])
expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([])
})
it('wall 整组递归检出(池+模板+提醒),同变量去重(全域消失才报)', () => {
const before = {
deflections: { en: ['call {intl}'] },
cc_templates: { echo: { en: 'at {intl}' } },
reminders: { cc_guide: { echo: '补全为 {intl}' } }
}
const after = {
deflections: { en: ['call me'] },
cc_templates: { echo: { en: 'at email' } },
reminders: { cc_guide: { echo: '补全为手机号' } }
}
expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
})
it('快照缺键不误报', () => {
expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
})
it('删除含变量的尾行/整语言键同样检出(位置无关)', () => {
const before = { deflections: { en: ['plain', 'call {intl}'] } }
expect(collectRemovedWallVariables(before, { deflections: { en: ['plain'] } })).toEqual(['intl'])
const before2 = { cc_templates: { echo: { en: 'at {intl}' } } }
expect(collectRemovedWallVariables(before2, { cc_templates: { echo: {} } })).toEqual(['intl'])
})
})
describe('WALL_SCENARIOS(场景卡定义)', () => {
it('两池场景卡:同场景 × 未留资/已留资(boundary_filter has_contact 语义)', () => {
const price = WALL_SCENARIOS.find((s) => s.key === 'price')
expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact'])
expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given'])
})
it('状态标签契约(ScenarioCard 消费)', () => {
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
})
})
```
- [ ] **Step 2: 跑测试确认失败**——预期 4 组 FAIL(函数未导出)
- [ ] **Step 3: 实现**——model.ts wall 区块(`buildWallPayload` 之后)插入:
```ts
// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护——spec §5.2) ------------------
/** 池语言并集(场景卡 tabs 数据源;必留语言在前、附加语言按字母序——排序稳定) */
export function poolLanguages(
pools: Record<string, Record<string, string[]>>
): string[] {
const set = new Set<string>()
for (const pool of Object.values(pools)) {
for (const lang of Object.keys(pool)) set.add(lang)
}
const required = POOL_REQUIRED_LANGS.filter((l) => set.has(l))
const extra = [...set]
.filter((l) => !(POOL_REQUIRED_LANGS as readonly string[]).includes(l))
.sort()
return [...required, ...extra]
}
/** 场景卡池位:field = wall 组键;when = 客户联系方式状态(boundary_filter has_contact) */
export interface ScenarioPool {
field: PoolField
when: 'not_given' | 'given'
}
export interface ScenarioDef {
key: 'price' | 'pivot'
title: string
note: string
pools: readonly ScenarioPool[]
}
/** 两张双状态场景卡(no_contact / cc_templates 结构非池,模板层直接渲染不走本表) */
export const WALL_SCENARIOS: readonly ScenarioDef[] = [
{
key: 'price',
title: '问价格等敏感话题怎么回',
note: '客户问到价格/优惠/交付等不便直接回答的话题时,AI 用这些话术引导留联系方式',
pools: [
{ field: 'deflections', when: 'not_given' },
{ field: 'deflections_contact', when: 'given' }
]
},
{
key: 'pivot',
title: '怎么把话题引到要联系方式',
note: 'AI 主动转向时用的话术——先回应客户话题,再顺势要 WhatsApp 或邮箱',
pools: [
{ field: 'pivots', when: 'not_given' },
{ field: 'pivots_contact', when: 'given' }
]
}
]
/** 池位状态 → 卡内小节标题 */
export const SCENARIO_WHEN_LABELS: Record<ScenarioPool['when'], string> = {
not_given: '客户还没留联系方式时',
given: '客户已经留过之后'
}
// ---- 模板变量保护({intl}/{shown}/{no_contact_web} 等运行期回填占位符) ---------------
/** 文本中的 {var} 占位符提取(字母/下划线开头,去重保序) */
export function templateVariables(text: string): string[] {
return [...new Set([...text.matchAll(/\{([a-z_][a-z0-9_]*)\}/gi)].map((m) => m[1]))]
}
/** before 出现过且 after 不再出现的变量(保存前警告清单) */
export function removedTemplateVariables(before: string, after: string): string[] {
const afterSet = new Set(templateVariables(after))
return templateVariables(before).filter((v) => !afterSet.has(v))
}
function collectVariables(value: unknown, out: string[]): void {
if (typeof value === 'string') {
out.push(...templateVariables(value))
} else if (value !== null && typeof value === 'object') {
// typeof 收窄只到 object(无索引签名)——断言收形
const record = value as Record<string, unknown>
for (const key of Object.keys(record)) collectVariables(record[key], out)
}
}
/** wall 整组文本区(池/no_contact/cc_templates/reminders)变量集差——
* before 出现过且 after 全域不再出现的变量(位置无关:删除尾行/整键同样检出) */
export function collectRemovedWallVariables(
before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined
): string[] {
const beforeVars: string[] = []
const afterVars: string[] = []
for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
collectVariables((before ?? {})[key], beforeVars)
collectVariables((after ?? {})[key], afterVars)
}
const afterSet = new Set(afterVars)
return [...new Set(beforeVars.filter((v) => !afterSet.has(v)))]
}
```
**注意:上面 WALL_SCENARIOS 的 pivot 卡第二个池位是笔误检查点——必须是 `{ field: 'pivots_contact', when: 'given' }`**(实现时以测试断言为准:`price.pools = [deflections, deflections_contact]`、`pivot.pools = [pivots, pivots_contact]`)。
- [ ] **Step 4: 跑测试确认通过**;**Step 5: Commit**——`git commit -m "feat(admin): wall 重组辅助纯函数(场景卡/语言并集/模板变量保护)"`
### Task 8: ScenarioCard.vue 场景卡
**Files:**
- Create: `admin/src/views/config/center/wall/ScenarioCard.vue`
- [ ] **Step 1: 创建组件**(要点全量):
```vue
<!--
双状态场景卡(spec §5.2):同一场景 × 未留资/已留资两池位(WALL_SCENARIOS)。
语言 tabs = poolLanguages(两池并集)(必留语言不可删;附加语言可删/可加);
每语言 tab 下两池位各一行编辑器(一行一句,增删行)。
直接改传入的 reactive WallEditModel.pools(与 WallPane 现状同模式)。
-->
<template>
<div class="scenario-card">
<div class="sc-head">
<div>
<p class="sc-title">{{ def.title }}</p>
<p class="sc-note">{{ def.note }}</p>
</div>
<ElTag v-if="requiredMissing.length" type="danger" size="small">
缺必留语言:{{ requiredMissing.join(' / ') }}
</ElTag>
</div>
<ElTabs v-if="langs.length" v-model="activeLang" class="sc-tabs">
<ElTabPane v-for="lang in langs" :key="lang" :name="lang">
<template #label>
<span class="sc-lang-label">
{{ lang }}
<span v-if="isExtraLang(lang)" class="sc-lang-del" title="删除此语言"
@click.prevent="removeLang(lang)">×</span>
</span>
</template>
<div v-for="pool in def.pools" :key="pool.field" class="sc-pool">
<p class="sc-pool-label">{{ SCENARIO_WHEN_LABELS[pool.when] }}</p>
<div v-for="(row, i) in poolRows(pool.field, lang)" :key="i" class="sc-row">
<ElInput
:model-value="row"
placeholder="话术句子(一行一句)"
class="sc-input"
@update:model-value="setPoolRow(pool.field, lang, i, $event)"
/>
<ElButton
link type="danger" size="small"
@click="removePoolRow(pool.field, lang, i)"
>删除</ElButton>
</div>
<ElButton size="small" text bg class="sc-add"
@click="addPoolRow(pool.field, lang)">+ 添加一句</ElButton>
</div>
</ElTabPane>
</ElTabs>
<p v-else class="sc-empty">还没有话术——添加第一个语言开始</p>
<div class="sc-new-lang">
<ElInput v-model="newLang" placeholder="语言代码如 pt / ru / ar" class="sc-new-lang-input" />
<ElButton size="small" @click="addLang">+ 添加语言</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import {
POOL_REQUIRED_LANGS,
SCENARIO_WHEN_LABELS,
poolLanguages,
type ScenarioDef,
type WallEditModel
} from '../model'
defineOptions({ name: 'WallScenarioCard' })
const props = defineProps<{
def: ScenarioDef
model: WallEditModel
}>()
const activeLang = ref('')
const newLang = ref('')
const langs = computed(() =>
poolLanguages(Object.fromEntries(props.def.pools.map((p) => [p.field, props.model.pools[p.field]])))
)
watch(
langs,
(list) => {
if (list.length && !list.includes(activeLang.value)) activeLang.value = list[0]
},
{ immediate: true }
)
const requiredMissing = computed(() =>
POOL_REQUIRED_LANGS.filter(
(l) => !langs.value.includes(l) || props.def.pools.some((p) => !(props.model.pools[p.field][l]?.length))
)
)
function isExtraLang(lang: string): boolean {
return !(POOL_REQUIRED_LANGS as readonly string[]).includes(lang)
}
function poolRows(field: string, lang: string): string[] {
return props.model.pools[field as keyof WallEditModel['pools']][lang] ?? []
}
function ensurePool(field: string, lang: string): string[] {
const pool = props.model.pools[field as keyof WallEditModel['pools']]
if (!pool[lang]) pool[lang] = []
return pool[lang]
}
function setPoolRow(field: string, lang: string, i: number, value: string): void {
ensurePool(field, lang)[i] = value
}
function addPoolRow(field: string, lang: string): void {
ensurePool(field, lang).push('')
}
function removePoolRow(field: string, lang: string, i: number): void {
ensurePool(field, lang).splice(i, 1)
}
function addLang(): void {
const code = newLang.value.trim().toLowerCase()
if (!/^[a-z]{2,3}$/.test(code)) {
ElMessage.warning('语言代码为 2-3 位字母(如 pt / ru / ar)')
return
}
for (const pool of props.def.pools) ensurePool(pool.field, code)
activeLang.value = code
newLang.value = ''
}
function removeLang(lang: string): void {
for (const pool of props.def.pools) delete props.model.pools[pool.field as keyof WallEditModel['pools']][lang]
}
</script>
```
样式(scoped scss):场景卡 `border:1px solid var(--el-border-color-lighter); border-radius:8px; padding:14px 16px`;sc-title 600 14px、sc-note 12px 灰、sc-pool-label 12px `var(--art-gray-500)` 带左侧 4px 色条(`border-left:3px solid var(--el-color-primary-light-5); padding-left:8px`)、sc-row flex(输入框 flex:1 + 删除钮)、sc-input 一行一句。
(auto-import:ref/computed/watch/ElMessage/ElTabs 等无需 import。)
- [ ] **Step 2: 构建验证**——`pnpm build` 零错误
- [ ] **Step 3: Commit**——`git commit -m "feat(admin): WallScenarioCard 双状态场景卡组件"`
### Task 9: WallAdvanced.vue 高级折叠区
**Files:**
- Create: `admin/src/views/config/center/wall/WallAdvanced.vue`
- 参考:`admin/src/views/config/center/WallPane.vue` 现状 53-210 行(正则区/池区模板迁出源)
- [ ] **Step 1: 创建组件**——把 WallPane 现状的 price_patterns/reask_patterns 正则行编辑器、triggers/zero_number 单正则区、reminders 六类区**原样迁入**(模板与逻辑不动,仅搬家),外包:
```vue
<template>
<div class="wall-advanced">
<ElAlert type="warning" :closable="false" class="wa-warn"
title="引擎级规则——改错可能导致拦截失灵或 AI 行为异常"
description="以下正则与指令由引擎直接消费;不确定的不要动;改坏可用版本历史回滚。" />
<ElCollapse class="wa-collapse">
<ElCollapseItem name="price_patterns" title="价格识别正则(price_patterns)">…迁移…</ElCollapseItem>
<ElCollapseItem name="reask_patterns" title="追问识别正则(reask_patterns)">…迁移…</ElCollapseItem>
<ElCollapseItem name="singles" title="触发词与零号码规则(triggers / zero_number)">…迁移…</ElCollapseItem>
<ElCollapseItem name="reminders" title="AI 提醒指令(reminders 六类)">…迁移…</ElCollapseItem>
</ElCollapse>
</div>
</template>
```
props `{ model: WallEditModel }`;迁移块内的 section-note 保留但首行加白话短句(如「识别客户在谈价格的句式」)。
- [ ] **Step 2: 构建验证**;**Step 3: Commit**——`git commit -m "feat(admin): WallAdvanced 高级折叠区(正则/提醒迁移)"`
### Task 10: WallPane 重组收口
**Files:**
- Modify: `admin/src/views/config/center/WallPane.vue`(大改:主体模板换结构)
- Modify: `admin/src/views/config/center/index.vue`(wall pane 挂载处加 `@navigate`)
- [ ] **Step 1: 主体重排**——外层换 `ConfigCard icon="solar:chat-round-line-duotone" title="AI 话术" subtitle="AI 回复客户时用的标准句与引擎规则——改动保存后立即对线上对话生效" info="保存前自动跑试金石预演;正则与 AI 指令在下方「高级」区"`;主面顺序:`<WallScenarioCard v-for="s in WALL_SCENARIOS" :key="s.key" :def="s" :model="model" />` → no_contact 卡(三键行编辑,NO_CONTACT_KEYS 白话标签:web=网页会话(有按钮)/ app=App 会话(无按钮)/ already=客户已留过)→ cc_templates 卡(四组 × CC_TEMPLATE_VARIANTS 封闭语言直接渲染,标注 `{intl}` 为运行期回填变量、不可删除)→ 试金石面板(现状保留)→ `<WallAdvanced :model="model" />`(默认折叠)。旧模板的池区/正则区/提醒区删除(已迁移)。**Task 8 评审折入三件**:① ScenarioCard 的 requiredMissing 收紧为「语言缺失或两池全部行皆空白」——`.some((p) => !(model.pools[p.field][l]?.some((t) => t.trim() !== '')))`(空白行保留但全空也预警,与 cleanPool 序列化语义对齐);② 场景卡 × 删除钮补键盘可达:`role="button" tabindex="0" aria-label="删除此语言" @keydown.enter.prevent="removeLang(lang)" @keydown.space.prevent="removeLang(lang)"`;③ 添加语言正则保持严格 `^[a-z]{2,3}$`(WallPane 旧池编辑器的宽正则随 Task 10 退役;异常存量码可显示可删不可重加——注释注记即可)
- [ ] **Step 2: 保存路径加变量保护**——onSave 组装 payload 后(ElMessageBox 须在 WallPane 顶部 `import { ElMessageBox } from 'element-plus'`——非 auto-import):
```ts
const removed = collectRemovedWallVariables(props.group, payload)
if (removed.length) {
const ok = await ElMessageBox.confirm(
`以下运行期变量在编辑中被删除:{${removed.join('}} {')}}——删除后 AI 无法回填对应内容,确定继续?`,
'模板变量被删除',
{ type: 'warning', confirmButtonText: '仍要保存', cancelButtonText: '回去检查' }
).catch(() => false)
if (!ok) return
}
```
(后续保持既有试金石预演 → PUT 流程不变。)
- [ ] **Step 3: 验证**——`pnpm build` 零错误;`pnpm dev` 走查:两场景卡语言切换/添加语言、必留语言 en/zh **无删除按钮**(其行可被逐条清空——此时卡头「缺必留语言」警示标签出现、后端 400 兜底,这是设计而非硬拦截)、no_contact/cc 卡可编辑、高级区默认折叠、试金石可用、保存一个改动走通试金石流程。注:spec §5.2「编辑器内变量高亮」有意裁剪为「保存时 collectRemovedWallVariables 确认 + cc 卡静态标注」——记为本计划终态,非遗漏
- [ ] **Step 4: 既有 wall 相关 vitest 全量**——`pnpm exec vitest run src/views/config/center/__tests__/model.test.ts` 全绿(initWallEdit/buildWallPayload 契约不回归)
- [ ] **Step 5: Commit**——`git commit -m "feat(admin): WallPane 重组为场景卡+高级折叠(Batch 2 收口)"`
---
## Batch 3:prompts 重组 + 三组表单化 + 脏保护(✅ 2026-10-14 完成——commits 8b43056/4dd74d6/250ffdf/edf58ce+e2aa166+26dc93b;全任务过双审;三拦截路径+头像双场景实测通过;挂账 Batch 4:CredsPane 单卡保存联动重置兄弟卡(BASE 起既有性状,修复形态=单卡保存只回写该卡 form+baseline))
### Task 11: PromptsPane 重组
**Files:**
- Modify: `admin/src/views/config/center/PromptsPane.vue`(360 行,模板重排)
- [ ] **Step 1: 主面/折叠重排**——外层换 `ConfigCard icon="solar:building-line-duotone" title="公司资料" subtitle="AI 向客户介绍你公司/产品时依据的内容——保持事实准确、精炼" info="AI 行为源头:改动必须填保存备注;每轮对话整段注入,超 50KB 禁存"`;主面 = company_facts 编辑器(PROMPTS_META 取 key==='company_facts' 项,字节计数保留);折叠区 ElCollapse「AI 引擎级提示词(高级——改错直接影响 AI 行为)」内 v-for 其余三件(system/extractor/evaluator,各自字节计数与 hint 保留)
- [ ] **Step 2: 验证**——`pnpm build`;dev 走查:折叠区未展开时保存,payload 仍含三件原值(读-改-写不丢——normalizePromptsGroup 从快照取全四键,保存必填备注逻辑不变)
- [ ] **Step 3: Commit**——`git commit -m "feat(admin): PromptsPane 重组(company_facts 主面+引擎级折叠)"`
### Task 12: model.ts 三组表单映射纯函数(TDD)
**Files:**
- Modify: `admin/src/views/config/center/model.ts`(文件末尾追加新区块;旧 JSON 组区块**暂留**——Task 13 接线后才退役,保证本任务收尾时仓库可构建)
- Test: `admin/src/views/config/center/__tests__/model.test.ts`(追加新用例;旧 JSON 组用例不动)
- [ ] **Step 1: 写失败测试**(model.test.ts 追加,import 行并入既有 from '../model' 名单):
```ts
import {
contactToForm, contactToPayload,
engineToForm, engineToPayload, validateEngineForm,
miscToForm, miscToPayload, validateKeyValueRows
} from '../model'
describe('contact 表单化', () => {
it('快照 → 表单(bool 缺省 false / 语言数组)', () => {
expect(contactToForm({ cc_completion: true, email_light_ask: false, template_languages: ['en', 'zh'] }))
.toEqual({ ccCompletion: true, emailLightAsk: false, templateLanguages: ['en', 'zh'] })
expect(contactToForm(null)).toEqual({ ccCompletion: false, emailLightAsk: false, templateLanguages: [] })
})
it('表单 → payload:接管键覆写 + 未知键透传', () => {
const snapshot = { cc_completion: false, email_light_ask: false, template_languages: [], _meta: 'keep' }
expect(contactToPayload({ ccCompletion: true, emailLightAsk: true, templateLanguages: ['en', ' '] }, snapshot))
.toEqual({ _meta: 'keep', cc_completion: true, email_light_ask: true, template_languages: ['en'] })
})
})
describe('engine 表单化', () => {
it('快照 → 表单(数值转串 / seed 映射转行)', () => {
expect(engineToForm({ rate_limit_per_hour: 120, ws_conn_limit: 5, context_seed_keys: { a: 'b' } }))
.toEqual({ rateLimit: '120', wsConnLimit: '5', seedRows: [{ key: 'a', value: 'b' }] })
expect(engineToForm(null)).toEqual({ rateLimit: '', wsConnLimit: '', seedRows: [] })
})
it('payload:空数值槽保快照原值(快照也无 → 键省略);未知键透传;seed 恒重建', () => {
const snapshot = { rate_limit_per_hour: 120, ws_conn_limit: 5, other: 1 }
expect(engineToPayload({ rateLimit: '', wsConnLimit: '9', seedRows: [] }, snapshot))
.toEqual({ other: 1, rate_limit_per_hour: 120, ws_conn_limit: 9, context_seed_keys: {} })
expect(engineToPayload({ rateLimit: '60', wsConnLimit: '', seedRows: [] }, null))
.toEqual({ rate_limit_per_hour: 60, context_seed_keys: {} })
})
it('payload:seed 行空键值剔除、重建映射', () => {
expect(engineToPayload(
{ rateLimit: '1', wsConnLimit: '1', seedRows: [{ key: 'product_name', value: 'app_product_name' }, { key: '', value: 'x' }] },
{}
).context_seed_keys).toEqual({ product_name: 'app_product_name' })
})
it('校验:非正整数报错', () => {
expect(validateEngineForm({ rateLimit: '0', wsConnLimit: '', seedRows: [] }))
.toEqual(['每 IP 每小时发言上限:须为正整数'])
expect(validateEngineForm({ rateLimit: '30', wsConnLimit: '2', seedRows: [] })).toEqual([])
})
})
describe('misc 表单化', () => {
it('往返:快照 → 表单 → payload 保未知键', () => {
const snapshot = { name_to_region_extras: { 'saudi arabia': '966' }, keep: true }
const form = miscToForm(snapshot)
expect(form.extrasRows).toEqual([{ key: 'saudi arabia', value: '966' }])
expect(miscToPayload({ extrasRows: [{ key: 'saudi arabia', value: '966' }, { key: 'uae', value: '971' }] }, snapshot))
.toEqual({ keep: true, name_to_region_extras: { 'saudi arabia': '966', uae: '971' } })
})
})
describe('KeyValue 行校验(engine seed / misc extras 共用)', () => {
it('重复键与空键报错', () => {
expect(validateKeyValueRows([{ key: 'a', value: '1' }, { key: 'a', value: '2' }], '映射'))
.toEqual(['映射:键 a 重复'])
expect(validateKeyValueRows([{ key: '', value: '1' }], '映射')).toEqual([])
})
})
```
- [ ] **Step 2: 跑测试确认失败**——新函数未定义 FAIL
- [ ] **Step 3: 实现**——model.ts 文件末尾(`JSON_GROUP_FIELDS` 区块之后)追加新区块:
```ts
// ---- contact / engine / misc 表单化(spec §5.4-5.7;JsonGroupPane 退役) --------------
export interface KeyValueRow {
key: string
value: string
}
/** 键值行校验(空行整行忽略;非空键重复报错;value 空允许——由后端校验语义) */
export function validateKeyValueRows(rows: KeyValueRow[], label: string): string[] {
const seen = new Set<string>()
const errors: string[] = []
for (const row of rows) {
if (row.key.trim() === '' && row.value.trim() === '') continue
const key = row.key.trim()
if (key === '') continue // 半行(有 value 无 key)序列化时整行剔除,不报错
if (seen.has(key)) errors.push(`${label}:键 ${key} 重复`)
seen.add(key)
}
return errors
}
function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
const out: Record<string, string> = {}
for (const row of rows) {
const key = row.key.trim()
if (key !== '') out[key] = row.value
}
return out
}
function recordToRows(value: unknown): KeyValueRow[] {
const src = asRecord(value) ?? {}
return Object.entries(src)
.filter(([, v]) => typeof v === 'string')
.map(([key, v]) => ({ key, value: v as string }))
}
// ---- contact ------------------------------------------------------------------------
export interface ContactForm {
ccCompletion: boolean
emailLightAsk: boolean
templateLanguages: string[]
}
export function contactToForm(
group: Record<string, unknown> | null | undefined
): ContactForm {
const src = group ?? {}
return {
ccCompletion: src.cc_completion === true,
emailLightAsk: src.email_light_ask === true,
templateLanguages: strList(src.template_languages)
}
}
export function contactToPayload(
form: ContactForm,
group: Record<string, unknown> | null | undefined
): Record<string, unknown> {
return {
...(group ?? {}),
cc_completion: form.ccCompletion,
email_light_ask: form.emailLightAsk,
template_languages: form.templateLanguages.map((s) => s.trim()).filter(Boolean)
}
}
```
```ts
// ---- engine -------------------------------------------------------------------------
export interface EngineForm {
/** 正整数串;'' = 未填(payload 保快照原值/省略键——绝不提交非法值) */
rateLimit: string
wsConnLimit: string
seedRows: KeyValueRow[]
}
function intToForm(value: unknown): string {
return typeof value === 'number' && Number.isInteger(value) ? String(value) : ''
}
export function engineToForm(
group: Record<string, unknown> | null | undefined
): EngineForm {
const src = group ?? {}
return {
rateLimit: intToForm(src.rate_limit_per_hour),
wsConnLimit: intToForm(src.ws_conn_limit),
seedRows: recordToRows(src.context_seed_keys)
}
}
/**
* 表单 → payload:空数值槽 = 保快照原值(spread 已带上;快照无值则不带键
* ——resolver 出厂回退),非空槽覆写;context_seed_keys 行编辑重建(空行
* 剔除);其余未知键透传。
*/
export function engineToPayload(
form: EngineForm,
group: Record<string, unknown> | null | undefined
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(group ?? {}) }
const rate = form.rateLimit.trim()
if (rate !== '') out.rate_limit_per_hour = Number(rate)
const ws = form.wsConnLimit.trim()
if (ws !== '') out.ws_conn_limit = Number(ws)
out.context_seed_keys = rowsToRecord(form.seedRows)
return out
}
```
```ts
export function validateEngineForm(form: EngineForm): string[] {
const errors: string[] = []
const rate = form.rateLimit.trim()
if (rate !== '' && (!/^\d+$/.test(rate) || Number(rate) < 1)) {
errors.push('每 IP 每小时发言上限:须为正整数')
}
const ws = form.wsConnLimit.trim()
if (ws !== '' && (!/^\d+$/.test(ws) || Number(ws) < 1)) {
errors.push('每 IP WebSocket 连接上限:须为正整数')
}
return errors
}
// ---- misc ---------------------------------------------------------------------------
export interface MiscForm {
extrasRows: KeyValueRow[]
}
export function miscToForm(
group: Record<string, unknown> | null | undefined
): MiscForm {
return { extrasRows: recordToRows((group ?? {}).name_to_region_extras) }
}
export function miscToPayload(
form: MiscForm,
group: Record<string, unknown> | null | undefined
): Record<string, unknown> {
return {
...(group ?? {}),
name_to_region_extras: rowsToRecord(form.extrasRows)
}
}
```
同时**不删**任何既有导出(`JsonGroupKey`/`jsonGroupDraft`/`parseJsonGroupDraft`/`JSON_GROUP_FIELDS` 与其测试原样保留,Task 13 统一退役——避免「model 删了函数但 index.vue 还在 import」的中间不可构建态)。
- [ ] **Step 4: 跑测试确认通过**——`pnpm exec vitest run src/views/config/center/__tests__/model.test.ts` 新 describe 全绿(旧用例不动仍绿)
- [ ] **Step 5: Commit**——`git commit -m "feat(admin): contact/engine/misc 表单映射纯函数"`
### Task 13: 三份表单 pane + KeyValueEditor + JsonGroup 退役
**Files:**
- Create: `admin/src/views/config/center/KeyValueEditor.vue`
- Create: `admin/src/views/config/center/ContactFormPane.vue`
- Create: `admin/src/views/config/center/EngineFormPane.vue`
- Create: `admin/src/views/config/center/MiscFormPane.vue`
- Delete: `admin/src/views/config/center/JsonGroupPane.vue`
- Modify: `admin/src/views/config/center/index.vue`(JSON_GROUPS 分发处换三组件 + 退役清理)
- Modify: `admin/src/views/config/center/model.ts`(删旧 JSON 组区块)
- Test: `admin/src/views/config/center/__tests__/model.test.ts`(删旧 JSON 组用例与 import 名单中的退役函数)
- [ ] **Step 1: KeyValueEditor.vue**——props `{ rows: KeyValueRow[]; keyPlaceholder: string; valuePlaceholder: string }`;模板 = 行循环(两 ElInput mono + 删除钮)+底部「+ 添加一行」;emit 无(直接改 reactive rows,与项目模式一致)
- [ ] **Step 2: ContactFormPane.vue**——ConfigCard `icon="solar:phone-line-duotone" title="联系方式" subtitle="AI 什么时候向客户要 WhatsApp / 邮箱"`;两个 ElSwitch(label 用 model 注释白话:cc_completion=「号码缺国家代码时自动补全并确认」/ email_light_ask=「留了号码没留邮箱时补要一次」);template_languages 用 ElSelect multiple allow-create(placeholder「语言代码如 en / zh」)+`<p class="field-hint">邮件通知使用的语言(暂未生效——引擎接线排期中)</p>`;保存走 contactToPayload、错误内联;footer/danger 同 Task 5 模式;emit saved/navigate
- [ ] **Step 3: EngineFormPane.vue**——ConfigCard `icon="solar:widget-5-line-duotone" title="聊天限流" subtitle="防刷与并发的引擎级参数(一般不用改)" info="留空 = 保持当前已存值;出厂档案缺省时省略该键回退平台默认"`;rate/ws 两 ElInput(placeholder「如 120(正整数)」/「如 5」)+推荐值提示;ElCollapse「会话上下文映射(context_seed_keys)」内 KeyValueEditor;保存 = validateEngineForm → engineToPayload → PUT engine
- [ ] **Step 4: MiscFormPane.vue**——ConfigCard `icon="solar:earth-line-duotone" title="国家区号映射" subtitle="客户用国名留言时,系统补全电话区号的额外规则"`;KeyValueEditor(key placeholder「国名(如 saudi arabia)」value「区号(如 966)」);validateKeyValueRows → miscToPayload → PUT misc
- [ ] **Step 5: index.vue 接线 + 退役清理**——`<JsonGroupPane>` 分发(index.vue:543 附近的 `v-if JSON_GROUPS.has(activeGroup)` 块)替换为三组件:`<ContactFormPane v-else-if="activeGroup==='contact'" :slug="activeSlug" :group="snapshot?.contact" @saved="onPaneSaved" @navigate="onGroupSelect" />` + EngineFormPane/MiscFormPane 同款三份(**必须 v-else-if**——该块处于 pane 条件链中段(brand v-if → … → v-else 占位卡),用 v-if 会让尾部 v-else 占位卡错挂;saved 接既有 handler 名 onPaneSaved——index.vue:630;新组件显式 import);同步删除 `JSON_GROUPS` 常量、`activeJsonGroup`、`JsonGroupPane` import 与 `JsonGroupKey` 相关 import;onPaneSaved 现有回写逻辑复用
- [ ] **Step 6: 退役删除**——`git rm admin/src/views/config/center/JsonGroupPane.vue`;model.ts 删「通用 JSON 组编辑」整段(`JsonGroupKey`/`jsonGroupDraft`/`parseJsonGroupDraft`/`JSON_GROUP_FIELDS`;`RESET_GROUP_LABELS` 保留不动);model.test.ts 删旧 JSON 组 describe 块与 import 名单中三个退役函数;`grep -rn "JsonGroupPane\|jsonGroupDraft\|parseJsonGroupDraft\|JSON_GROUP_FIELDS" admin/src` 零残留
- [ ] **Step 7: 验证**——`pnpm build`;`pnpm test` 全绿(总数 = 基线 − 旧 JSON 组用例数 + Task 12 新增数);dev 走查三组表单(含未知键透传:后台给某租户 contact 组塞 `_custom: 1` 再 GET → 表单保存 → `_custom` 仍在——可用平台 API 或直接 sqlite 验证)
- [ ] **Step 8: Commit**——`git commit -m "feat(admin): contact/engine/misc 表单 pane + JsonGroup 退役"`
### Task 14: 未保存离开保护(脏检测 + 三处拦截)
**Files:**
- Modify: `admin/src/views/config/center/index.vue`(dirty 汇聚 + 三处拦截)
- Modify: `admin/src/views/config/center/WallPane.vue` / `PromptsPane.vue` / `CredsPane.vue`(emit dirty)
- Modify: `admin/src/views/config/center/ContactFormPane.vue` / `EngineFormPane.vue` / `MiscFormPane.vue`(emit dirty)
- [ ] **Step 1: 各 pane 上报 dirty**——七个编辑面(brand 在 index.vue 内联)各加 `const dirty = computed(() => ...)`(判定 = 当前编辑态 JSON.stringify ≠ 初始归一态 JSON.stringify:brand 对比表单初值、prompts 对比 normalizePromptsGroup、wall 对比 initWallEdit 克隆、creds 对比三组 normalize 态、三表单对比 ToForm 初值),`watch(dirty, (v) => emit('dirty', v), { immediate: true })`;emits 增加 `(e: 'dirty', value: boolean): void`;index.vue 记录 `const paneDirty = ref(false)`,各 pane `@dirty="paneDirty = $event"`
- [ ] **Step 2: 三处拦截**——index.vue(ElMessageBox 顶部显式 import):
```ts
async function confirmDiscard(): Promise<boolean> {
if (!paneDirty.value) return true
try {
await ElMessageBox.confirm('有未保存的修改,离开将丢失。确定离开?', '未保存的修改',
{ type: 'warning', confirmButtonText: '丢弃修改', cancelButtonText: '留下' })
return true
} catch {
return false
}
}
async function onGroupSelect(index: string): Promise<void> {
if (index === activeGroup.value) return
if (!(await confirmDiscard())) return
activeGroup.value = index as GroupKey
}
watch(activeSlug, async () => {
if (!(await confirmDiscard())) {
// 还原租户选择:nextTick 后把 ElSelect 绑定值恢复——实现时用「旧值快照」回写
}
// 原有重载逻辑
})
```
租户切换的还原实现:watch(activeSlug) 先比对模式(实现时以 ElSelect change 事件前置确认代替 watch 更稳:改用 `@change` 处理器承载确认+回滚,确认失败则 `activeSlug.value = 旧值`)。路由离开:`onBeforeRouteLeave(async () => await confirmDiscard())`
- [ ] **Step 3: 验证**——`pnpm build`;dev 走查:brand 改标题不保存 → 切导航组弹确认;wall 改句子 → 切租户弹确认、取消后租户选择还原、草稿还在;保存后切换不弹
- [ ] **Step 4: Commit**——`git commit -m "feat(admin): 未保存离开保护(Batch 3 收口)"`
---
## Batch 4:轻量打磨 + 全局回归(✅ 2026-10-14 完成——commits f92a4be/c6bd542/1df1fc7;终审 Merge-ready(0C/0I/4M);182 vitest 全绿 + build 零错误 + 暗色两态验收 + 跨租户泄漏缝实测关闭。Minor 处置:#1 试金石异步窗口旧 payload=既有性状挂账(可选加固:确认链后重建 payload 或保存中遮罩);#2 WallAdvanced 陈旧注释已修;#3 标签宽度逐卡一致但跨卡不统一=接受现状(cosmetic);#4 三表单 pane configLoaded 门控不对称=补注记归后续。色列宽度/头像提示样式两打磨项经暗色+走查目检视觉可接受,结项)
### Task 15: 其他页面文案/空态轻量打磨
**Files:**
- Modify: `admin/src/views/dashboard/console/index.vue`、`admin/src/views/conversations/list/index.vue`、`admin/src/views/stats/overview/index.vue`、`admin/src/views/settings/platform/index.vue`、`admin/src/views/tenants/list/index.vue`
- [ ] **Step 1: 定位**——`grep -rn "ElEmpty\|empty-description\|description=" admin/src/views/{dashboard,conversations,stats,settings,tenants}` 列出全部空态点
- [ ] **Step 2: 逐页轻改**——原则(spec §7):空态从「暂无数据」改具体动作指引(会话页「暂无会话——客户开始聊天后会出现在这里」/统计页「暂无数据——产生会话后展示分析」/平台设置存储表空「暂无设置项——从上方推荐键开始」);提示文案去术语(「线索」统一、错误 toast 主语明确)。**只改文案与空态,不动布局/逻辑**;每页改完 dev 目检
- [ ] **Step 3: Commit**——`git commit -m "polish(admin): 各页文案与空态轻量打磨"`
> 处置注记(Task 15 收尾):色列宽度与头像提示样式打磨经 Task 16 走查目检后定夺(视觉可接受则结项,否则列入后续)。
### Task 16: 全局回归 + 验收
- [ ] **Step 1: 前端全量测试**——`cd admin && pnpm test`,预期全绿且总数 ≥ 改造前(既有用例随退役迁移、新增只增不减;当前基线 164)
- [ ] **Step 2: 构建 + 行尾纪律**——`pnpm build` 零错误;`git diff --ignore-cr-at-eol --stat` 若仅 components.d.ts/auto-imports.d.ts → `git checkout --` 还原
- [ ] **Step 3: 手动走查(spec §10.3 任务流)**——dev server + 8018 后端:改品牌→改话术(含添加语言 pt)→填 SMTP→测试按钮→开关号码补全→版本历史回滚;明/暗主题切换检查全 pane(顶栏切换钮);未保存离开保护三路径复验
- [ ] **Step 4: 后端不动确认**——`git status` 应零 app/ 改动;pytest 无需跑(后端零改动,非 seed_profiles 变更)
- [ ] **Step 5: 收尾**——临时产物(截图/probe)删除,`git status --short` 干净;`git commit -m "chore: 配置中心优化收尾"`(如有残余杂项)
---
## 附:任务依赖与并行性
- Task 1→2→3 顺序;Task 4 独立(可与 2/3 并行);Task 5 依赖 2+3;Task 6 依赖 3
- Batch 2 内 7→8、7→9(8/9 可并行)→10 依赖 8+9
- Batch 3 内 11 独立;12→13→14 顺序(14 依赖 13 的三 pane 存在)
- Batch 4 顺序收口