` 包 ElFormItem:服务器/端口同排、账号/发件人同排、密码独占、通知邮箱独占——样式 `.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 16px; .span-2 { grid-column:span 2 } }`);SMTP 密码整块换 `
`(原 secret-row/清除按钮/field-warn 模板删除);底部操作条:
+
+```html
+
+
+ 出问题?回滚 →
+
+
+
+
+ 保存
+
+
+
+ 重置出厂…
+
+
+```
+
+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 行)
+
+- [ ] **Step 1: 表单卡换 ConfigCard**——`icon="solar:palette-round-line-duotone" title="品牌与外观" subtitle="客户看到的聊天窗口长什么样" info="整组保存 · 立即生效;图片与链接留空 = 不展示"`;短字段(标题/主色/WhatsApp/邮箱/占位提示)双列 grid、话题标签与隐私链接独占;footer/danger 同 Task 5 模式(footer-left 回滚、footer-right 备注+保存、danger 重置出厂)
+- [ ] **Step 2: 预览与嵌入代码卡理顺**——预览卡与嵌入代码卡紧随表单卡,预览卡头部加副标题「本地简化预览(真机效果以嵌入站点为准)」
+- [ ] **Step 3: 验证 + Commit**——`pnpm build` + dev 走查(改标题/颜色预览即时反映);`git commit -m "feat(admin): 品牌卡套视觉基线(Batch 1 收口)"`
+
+---
+
+## Batch 2:wall 重组(场景卡 + 高级折叠)
+
+### 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 } 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 {intl}' } },
+ reminders: { cc_guide: { echo: '补全为 {intl}' } }
+ }
+ expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
+ })
+ it('快照缺键不误报', () => {
+ expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
+ })
+})
+
+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'])
+ })
+})
+```
+
+- [ ] **Step 2: 跑测试确认失败**——预期 4 组 FAIL(函数未导出)
+- [ ] **Step 3: 实现**——model.ts wall 区块(`buildWallPayload` 之后)插入:
+
+```ts
+// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护——spec §5.2) ------------------
+
+/** 池语言并集(场景卡 tabs 数据源;必留语言在前、附加语言按字母序——排序稳定) */
+export function poolLanguages(
+ pools: Record
>
+): string[] {
+ const set = new Set()
+ 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 = {
+ 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 collectRemovedStrings(before: unknown, after: unknown, out: string[]): void {
+ if (typeof before === 'string' && typeof after === 'string') {
+ out.push(...removedTemplateVariables(before, after))
+ } else if (
+ before !== null && after !== null &&
+ typeof before === 'object' && typeof after === 'object'
+ ) {
+ const b = before as Record
+ const a = after as Record
+ for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out)
+ }
+}
+
+/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */
+export function collectRemovedWallVariables(
+ before: Record | null | undefined,
+ after: Record | null | undefined
+): string[] {
+ const out: string[] = []
+ const b = before ?? {}
+ const a = after ?? {}
+ for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
+ collectRemovedStrings(b[key], a[key], out)
+ }
+ return [...new Set(out)]
+}
+```
+
+**注意:上面 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
+
+
+
+
+
+
{{ def.title }}
+
{{ def.note }}
+
+
+ 缺必留语言:{{ requiredMissing.join(' / ') }}
+
+
+
+
+
+
+
+ {{ lang }}
+ ×
+
+
+
+
{{ SCENARIO_WHEN_LABELS[pool.when] }}
+
+
+ 删除
+
+
+ 添加一句
+
+
+
+
还没有话术——添加第一个语言开始
+
+
+
+ + 添加语言
+
+
+
+
+
+```
+
+样式(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
+
+
+
+
+ …迁移…
+ …迁移…
+ …迁移…
+ …迁移…
+
+
+
+```
+
+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 指令在下方「高级」区"`;主面顺序:`` → no_contact 卡(三键行编辑,NO_CONTACT_KEYS 白话标签:web=网页会话(有按钮)/ app=App 会话(无按钮)/ already=客户已留过)→ cc_templates 卡(四组 × CC_TEMPLATE_VARIANTS 封闭语言直接渲染,标注 `{intl}` 为运行期回填变量、不可删除)→ 试金石面板(现状保留)→ ``(默认折叠)。旧模板的池区/正则区/提醒区删除(已迁移)
+- [ ] **Step 2: 保存路径加变量保护**——onSave 组装 payload 后:
+
+```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 被拦)、no_contact/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 重组 + 三组表单化 + 脏保护
+
+### 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,JsonGroup 函数退役)
+
+**Files:**
+- Modify: `admin/src/views/config/center/model.ts`(通用 JSON 组区块整体替换)
+- Test: `admin/src/views/config/center/__tests__/model.test.ts`(JSON 组旧测试删除,新测试替换)
+
+- [ ] **Step 1: 写失败测试**(jsonGroupDraft/parseJsonGroupDraft 的旧 describe 块先删除):
+
+```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:空数值槽保快照原值(快照也无 → 键省略);未知键透传', () => {
+ 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 })
+ expect(engineToPayload({ rateLimit: '60', wsConnLimit: '', seedRows: [] }, null))
+ .toEqual({ rate_limit_per_hour: 60 })
+ })
+ 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: 实现**——「通用 JSON 组编辑」区块(`JsonGroupKey` 到 `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()
+ 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 {
+ const out: Record = {}
+ 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 | 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 | null | undefined
+): Record {
+ 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 | 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 | null | undefined
+): Record {
+ const out: Record = { ...(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 | null | undefined
+): MiscForm {
+ return { extrasRows: recordToRows((group ?? {}).name_to_region_extras) }
+}
+
+export function miscToPayload(
+ form: MiscForm,
+ group: Record | null | undefined
+): Record {
+ return {
+ ...(group ?? {}),
+ name_to_region_extras: rowsToRecord(form.extrasRows)
+ }
+}
+```
+
+同时:`JsonGroupKey`/`jsonGroupDraft`/`parseJsonGroupDraft`/`JSON_GROUP_FIELDS` 全部删除;`RESET_GROUP_LABELS` 保留不动(确认框用,含 email/push/ai 三凭据组)。model.test.ts 中引用退役函数的旧用例删除。
+
+- [ ] **Step 4: 跑测试确认通过**(新 describe 全绿 + 无退役函数残留引用导致的全量失败——`pnpm test` 全量跑)
+- [ ] **Step 5: Commit**——`git commit -m "feat(admin): contact/engine/misc 表单映射纯函数(JsonGroup 函数退役)"`
+
+### Task 13: 三份表单 pane + KeyValueEditor
+
+**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`
+- Modify: `admin/src/views/config/center/index.vue`(JSON_GROUPS 分发处换三组件 + navigate 事件接线)
+
+- [ ] **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」)+`邮件通知使用的语言(暂未生效——引擎接线排期中)
`;保存走 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 接线**——`JSON_GROUPS` 三元的 `` 分发(index.vue:543 附近的 v-if JSON_GROUPS.has(activeGroup) 块)替换为三组件各挂 `` 同款三份;onGroupSaved 现有回写逻辑复用
+- [ ] **Step 6: 验证**——`pnpm build`;`pnpm test` 全绿;dev 走查三组表单(含未知键透传:在 platform.db 里给某租户 contact 组塞 `_custom: 1` 再 GET → 表单保存 → `_custom` 仍在——可用后台 API 或直接 sqlite 验证)
+- [ ] **Step 7: Commit**——`git commit -m "feat(admin): contact/engine/misc 表单 pane(KeyValueEditor 共享)"`
+
+### Task 14: JsonGroupPane 删除 + 未保存离开保护
+
+**Files:**
+- Delete: `admin/src/views/config/center/JsonGroupPane.vue`
+- 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: 删除 JsonGroupPane.vue**——`git rm admin/src/views/config/center/JsonGroupPane.vue`;grep `JsonGroupPane` 确认零残留(含 reset.ts 与测试)
+- [ ] **Step 2: 各 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 3: 三处拦截**——index.vue:
+
+```ts
+async function confirmDiscard(): Promise {
+ if (!paneDirty.value) return true
+ try {
+ await ElMessageBox.confirm('有未保存的修改,离开将丢失。确定离开?', '未保存的修改',
+ { type: 'warning', confirmButtonText: '丢弃修改', cancelButtonText: '留下' })
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function onGroupSelect(index: string): Promise {
+ if (index === activeGroup.value) return
+ if (!(await confirmDiscard())) return
+ activeGroup.value = index as GroupKey
+}
+
+watch(activeSlug, async () => {
+ if (!(await confirmDiscard())) {
+ // 还原租户选择:nextTick 后把 ElSelect 绑定值恢复——实现时用「旧值快照」回写
+ }
+ // 原有重载逻辑
+})
+```
+
+租户切换的还原实现:`const lastConfirmedSlug = ref('')`——watch(activeSlug) 先比对 `pendingSlug` 模式(实现时以 ElSelect change 事件前置确认代替 watch 更稳:改用 `@change` 处理器承载确认+回滚,确认失败则 `activeSlug.value = 旧值`)。路由离开:`onBeforeRouteLeave(async () => await confirmDiscard())`
+
+- [ ] **Step 4: 验证**——`pnpm build`;dev 走查:brand 改标题不保存 → 切导航组弹确认;wall 改句子 → 切租户弹确认、取消后租户选择还原、草稿还在;保存后切换不弹
+- [ ] **Step 5: Commit**——`git commit -m "feat(admin): 未保存离开保护 + JsonGroupPane 退役(Batch 3 收口)"`
+
+---
+
+## Batch 4:轻量打磨 + 全局回归
+
+### 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 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 顺序收口