2026-10-14 16:25:59 +08:00
# 配置中心易用性与视觉优化 实施计划
> **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` )
2026-10-14 16:36:05 +08:00
- 构建:`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 同款)
2026-10-14 16:25:59 +08:00
- 本地验收环境:Docker 容器 all-kefu-app-1( 8018, `/admin/` )跑的是**构建产物**——前端改动本地验收用 dev server:`cd admin && pnpm dev` (默认 3006,代理已配好);超管 admin / 密码 12345678(本机测试值);登录滑块为纯前端校验可直接拖
- 每个任务收尾 `git status --short` 应只剩本任务相关文件;截图等临时产物放 `.superpowers/` (已 gitignore),收尾删净
---
2026-10-14 18:46:41 +08:00
## Batch 1:视觉基线 + SecretField + 导航分层 + brand/creds(✅ 2026-10-14 完成——commits 0a41352/6881ccc/2e00db1+46dfbef/712c5f9/06e28e4/680c4ba;全任务过双审;视觉走查通过;遗留打磨项记 Task 15:色列宽度平衡/预览卡文案去重/头像提示样式)
2026-10-14 16:25:59 +08:00
### 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 : 8 px ;
. secret - input {
flex : 1 ;
min - width : 0 ;
}
. secret - badge {
flex - shrink : 0 ;
}
}
. field - warn {
margin : 4 px 0 0 ;
font - size : 12 px ;
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 />
2026-10-14 17:21:38 +08:00
< div v-if = "$slots['footer-left'] || $slots['footer-right']" class="cc-footer" >
2026-10-14 16:25:59 +08:00
< 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 : 14 px 20 px 10 px ;
}
: deep (. el - card__body ) {
padding : 14 px 20 px ;
}
}
. cc - header {
. cc - title - row {
display : flex ;
align - items : center ;
gap : 8 px ;
. cc - icon {
font - size : 18 px ;
2026-10-14 17:24:35 +08:00
color : var ( -- art - primary , var ( -- el - color - primary ));
2026-10-14 16:25:59 +08:00
}
. cc - title {
font - weight : 600 ;
font - size : 15 px ;
2026-10-14 17:24:35 +08:00
color : var ( -- el - text - color - primary );
2026-10-14 16:25:59 +08:00
}
}
. cc - subtitle {
margin : 2 px 0 0 ;
font - size : 12 px ;
color : var ( -- art - gray - 500 , var ( -- el - text - color - secondary ));
}
. cc - info {
display : flex ;
align - items : flex - start ;
gap : 6 px ;
margin - top : 8 px ;
padding : 7 px 12 px ;
border - radius : 6 px ;
background : var ( -- el - fill - color - lighter );
font - size : 12 px ;
line - height : 1.6 ;
color : var ( -- art - gray - 500 , var ( -- el - text - color - secondary ));
. cc - info - icon {
flex - shrink : 0 ;
font - size : 14 px ;
color : var ( -- art - gray - 400 , var ( -- el - text - color - placeholder ));
}
}
}
. cc - footer {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
margin - top : 14 px ;
padding - top : 12 px ;
border - top : 1 px dashed var ( -- el - border - color - lighter );
. cc - footer - right {
display : flex ;
align - items : center ;
gap : 8 px ;
}
}
. cc - danger {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
margin - top : 10 px ;
padding : 7 px 12 px ;
border : 1 px solid var ( -- el - color - danger - light - 7 );
border - radius : 6 px ;
background : var ( -- el - color - danger - light - 9 );
. cc - danger - text {
font - size : 12 px ;
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
]
```
2026-10-14 16:41:45 +08:00
`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'
```
2026-10-14 16:25:59 +08:00
- [ ] **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` (三卡模板重排,脚本层函数全部保留)
2026-10-14 16:36:05 +08:00
- [ ] **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 无关);底部操作条:
2026-10-14 16:25:59 +08:00
```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:**
2026-10-14 18:26:18 +08:00
- Modify: `admin/src/views/config/center/index.vue` ( brand pane 模板区,约 99-260 行;另两处小修)
- Modify: `admin/src/views/config/center/CredsPane.vue` (三处 info 文案去「带 • 的」前缀)
2026-10-14 16:25:59 +08:00
2026-10-14 18:26:18 +08:00
- [ ] **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)
2026-10-14 16:25:59 +08:00
- [ ] **Step 2: 预览与嵌入代码卡理顺** ——预览卡与嵌入代码卡紧随表单卡,预览卡头部加副标题「本地简化预览(真机效果以嵌入站点为准)」
2026-10-14 18:26:18 +08:00
- [ ] **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 收口)"`
2026-10-14 16:25:59 +08:00
---
2026-10-14 19:45:45 +08:00
## Batch 2: wall 重组(场景卡 + 高级折叠)(✅ 2026-10-14 完成——commits 2e6811a+36fe534/ab0cf9f/0553ccc/12b8dab;全任务过双审;视觉+交互走查通过:场景卡语言切换/试金石预演实测;遗留:试金石异步窗口内编辑被旧 payload 覆盖(既有性状,可选加固))
2026-10-14 16:25:59 +08:00
### 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
2026-10-14 19:03:36 +08:00
import { poolLanguages , templateVariables , removedTemplateVariables , collectRemovedWallVariables , WALL_SCENARIOS , SCENARIO_WHEN_LABELS } from '../model'
2026-10-14 16:25:59 +08:00
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 ([])
})
2026-10-14 19:03:36 +08:00
it ( 'wall 整组递归检出(池+模板+提醒),同变量去重(全域消失才报)' , () => {
2026-10-14 16:25:59 +08:00
const before = {
deflections : { en : [ 'call {intl}' ] },
cc_templates : { echo : { en : 'at {intl}' } },
reminders : { cc_guide : { echo : '补全为 {intl}' } }
}
const after = {
deflections : { en : [ 'call me' ] },
2026-10-14 19:03:36 +08:00
cc_templates : { echo : { en : 'at email' } },
reminders : { cc_guide : { echo : '补全为手机号' } }
2026-10-14 16:25:59 +08:00
}
expect ( collectRemovedWallVariables ( before , after )). toEqual ([ 'intl' ])
})
it ( '快照缺键不误报' , () => {
expect ( collectRemovedWallVariables ({}, { deflections : { en : [ 'x' ] } })). toEqual ([])
})
2026-10-14 19:03:36 +08:00
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' ])
})
2026-10-14 16:25:59 +08:00
})
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' ])
})
2026-10-14 19:03:36 +08:00
it ( '状态标签契约(ScenarioCard 消费)' , () => {
expect ( SCENARIO_WHEN_LABELS ). toEqual ({ not_given : '客户还没留联系方式时' , given : '客户已经留过之后' })
})
2026-10-14 16:25:59 +08:00
})
```
- [ ] **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 ))
}
2026-10-14 19:03:36 +08:00
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 )
2026-10-14 16:25:59 +08:00
}
}
2026-10-14 19:03:36 +08:00
/** wall 整组文本区(池/no_contact/cc_templates/reminders)变量集差——
* before 出现过且 after 全域不再出现的变量(位置无关:删除尾行/整键同样检出) */
2026-10-14 16:25:59 +08:00
export function collectRemovedWallVariables (
before : Record < string , unknown > | null | undefined ,
after : Record < string , unknown > | null | undefined
) : string [] {
2026-10-14 19:03:36 +08:00
const beforeVars : string [] = []
const afterVars : string [] = []
2026-10-14 16:25:59 +08:00
for ( const key of [... POOL_FIELDS , 'no_contact' , 'cc_templates' , 'reminders' ]) {
2026-10-14 19:03:36 +08:00
collectVariables (( before ?? {})[ key ], beforeVars )
collectVariables (( after ?? {})[ key ], afterVars )
2026-10-14 16:25:59 +08:00
}
2026-10-14 19:03:36 +08:00
const afterSet = new Set ( afterVars )
return [... new Set ( beforeVars . filter (( v ) => ! afterSet . has ( v )))]
2026-10-14 16:25:59 +08:00
}
```
**注意:上面 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` )
2026-10-14 19:16:55 +08:00
- [ ] **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 退役;异常存量码可显示可删不可重加——注释注记即可)
2026-10-14 16:36:05 +08:00
- [ ] **Step 2: 保存路径加变量保护** ——onSave 组装 payload 后(ElMessageBox 须在 WallPane 顶部 `import { ElMessageBox } from 'element-plus'` ——非 auto-import):
2026-10-14 16:25:59 +08:00
```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 流程不变。)
2026-10-14 16:36:05 +08:00
- [ ] **Step 3: 验证** ——`pnpm build` 零错误;`pnpm dev` 走查:两场景卡语言切换/添加语言、必留语言 en/zh **无删除按钮** (其行可被逐条清空——此时卡头「缺必留语言」警示标签出现、后端 400 兜底,这是设计而非硬拦截)、no_contact/cc 卡可编辑、高级区默认折叠、试金石可用、保存一个改动走通试金石流程。注:spec §5.2「编辑器内变量高亮」有意裁剪为「保存时 collectRemovedWallVariables 确认 + cc 卡静态标注」——记为本计划终态,非遗漏
2026-10-14 16:25:59 +08:00
- [ ] **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 收口)"`
---
2026-10-14 21:16:10 +08:00
## Batch 3: prompts 重组 + 三组表单化 + 脏保护(✅ 2026-10-14 完成——commits 8b43056/4dd74d6/250ffdf/edf58ce+e2aa166+26dc93b;全任务过双审;三拦截路径+头像双场景实测通过;挂账 Batch 4:CredsPane 单卡保存联动重置兄弟卡(BASE 起既有性状,修复形态=单卡保存只回写该卡 form+baseline))
2026-10-14 16:25:59 +08:00
### 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 主面+引擎级折叠)"`
2026-10-14 16:36:05 +08:00
### Task 12: model.ts 三组表单映射纯函数(TDD)
2026-10-14 16:25:59 +08:00
**Files:**
2026-10-14 16:36:05 +08:00
- Modify: `admin/src/views/config/center/model.ts` (文件末尾追加新区块;旧 JSON 组区块**暂留**——Task 13 接线后才退役,保证本任务收尾时仓库可构建)
- Test: `admin/src/views/config/center/__tests__/model.test.ts` (追加新用例;旧 JSON 组用例不动)
2026-10-14 16:25:59 +08:00
2026-10-14 16:36:05 +08:00
- [ ] **Step 1: 写失败测试** ( model.test.ts 追加,import 行并入既有 from '../model' 名单):
2026-10-14 16:25:59 +08:00
```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 : [] })
})
2026-10-14 16:36:05 +08:00
it ( 'payload:空数值槽保快照原值(快照也无 → 键省略);未知键透传;seed 恒重建' , () => {
2026-10-14 16:25:59 +08:00
const snapshot = { rate_limit_per_hour : 120 , ws_conn_limit : 5 , other : 1 }
expect ( engineToPayload ({ rateLimit : '' , wsConnLimit : '9' , seedRows : [] }, snapshot ))
2026-10-14 16:36:05 +08:00
. toEqual ({ other : 1 , rate_limit_per_hour : 120 , ws_conn_limit : 9 , context_seed_keys : {} })
2026-10-14 16:25:59 +08:00
expect ( engineToPayload ({ rateLimit : '60' , wsConnLimit : '' , seedRows : [] }, null ))
2026-10-14 16:36:05 +08:00
. toEqual ({ rate_limit_per_hour : 60 , context_seed_keys : {} })
2026-10-14 16:25:59 +08:00
})
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
2026-10-14 16:36:05 +08:00
- [ ] **Step 3: 实现** ——model.ts 文件末尾(`JSON_GROUP_FIELDS` 区块之后)追加新区块:
2026-10-14 16:25:59 +08:00
```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 )
}
}
```
2026-10-14 16:36:05 +08:00
同时**不删**任何既有导出(`JsonGroupKey` /`jsonGroupDraft` /`parseJsonGroupDraft` /`JSON_GROUP_FIELDS` 与其测试原样保留,Task 13 统一退役——避免「model 删了函数但 index.vue 还在 import」的中间不可构建态)。
2026-10-14 16:25:59 +08:00
2026-10-14 16:36:05 +08:00
- [ ] **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 表单映射纯函数"`
2026-10-14 16:25:59 +08:00
2026-10-14 16:36:05 +08:00
### Task 13: 三份表单 pane + KeyValueEditor + JsonGroup 退役
2026-10-14 16:25:59 +08:00
**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`
2026-10-14 16:36:05 +08:00
- 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 名单中的退役函数)
2026-10-14 16:25:59 +08:00
- [ ] **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
2026-10-14 16:41:45 +08:00
- [ ] **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 现有回写逻辑复用
2026-10-14 16:36:05 +08:00
- [ ] **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 退役"`
2026-10-14 16:25:59 +08:00
2026-10-14 16:36:05 +08:00
### Task 14: 未保存离开保护(脏检测 + 三处拦截)
2026-10-14 16:25:59 +08:00
**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)
2026-10-14 16:36:05 +08:00
- [ ] **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):
2026-10-14 16:25:59 +08:00
```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 绑定值恢复——实现时用「旧值快照」回写
}
// 原有重载逻辑
})
```
2026-10-14 16:36:05 +08:00
租户切换的还原实现:watch(activeSlug) 先比对模式(实现时以 ElSelect change 事件前置确认代替 watch 更稳:改用 `@change` 处理器承载确认+回滚,确认失败则 `activeSlug.value = 旧值` )。路由离开:`onBeforeRouteLeave(async () => await confirmDiscard())`
2026-10-14 16:25:59 +08:00
2026-10-14 16:36:05 +08:00
- [ ] **Step 3: 验证** ——`pnpm build` ;dev 走查:brand 改标题不保存 → 切导航组弹确认;wall 改句子 → 切租户弹确认、取消后租户选择还原、草稿还在;保存后切换不弹
- [ ] **Step 4: Commit** ——`git commit -m "feat(admin): 未保存离开保护(Batch 3 收口)"`
2026-10-14 16:25:59 +08:00
---
2026-10-14 21:56:02 +08:00
## 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 门控不对称=补注记归后续。色列宽度/头像提示样式两打磨项经暗色+走查目检视觉可接受,结项)
2026-10-14 16:25:59 +08:00
### 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): 各页文案与空态轻量打磨"`
2026-10-14 21:40:29 +08:00
> 处置注记(Task 15 收尾):色列宽度与头像提示样式打磨经 Task 16 走查目检后定夺(视觉可接受则结项,否则列入后续)。
2026-10-14 16:25:59 +08:00
### 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 顺序收口