Files
ai-kefu-admin/docs/superpowers/plans/2026-10-14-config-center-ux-visual.md
T
浅念 1df1fc7922 fix(admin): 重建 watch 补 slug 源(防缺组租户切换草稿泄漏)
c6bd542 拆分后的分组 watch 只盯组 props 引用——seed 租户快照可能缺组
(如无 ai/contact/misc 组),两家租户都缺时切租户该组 prop 走
undefined→undefined 引用不变、watch 不触发,上一家租户的草稿留在当前
租户界面(dirty 也残留)。旧的全量耦合顺带防住了它,拆分后暴露。

修复:六个 pane 的重建 watch 源统一加 () => props.slug 兜底——slug 变化
即强制重建(缺组时空槽归位、dirty 归 false);单卡保存路径不受影响
(组引用照常换新触发)。覆盖 CredsPane 三卡 + WallPane / PromptsPane /
ContactFormPane / EngineFormPane / MiscFormPane(均已声明 slug prop,
index.vue 挂载处均已传 activeSlug,零接线改动)。

顺手:CredsPane 测试态注释仍提已删除的 resetAll → 改提三卡 watch;
计划文档 Task 15 段落末补 Batch 1 遗留打磨项处置注记(色列宽度/头像
提示样式经 Task 16 走查目检后定夺)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 21:40:29 +08:00

59 KiB
Raw Blame History

配置中心易用性与视觉优化 实施计划

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 追加:

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 之后)插入:

// ---- 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: 创建组件(全量代码):

<!--
  密钥字段四件套(视觉基线 §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: 创建组件(全量代码):

<!--
  视觉基线卡壳(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 区块改为:

  /** 导航项:任务名 + 副标题(导航与卡头共用文案源) */
  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):

type GroupKey =
  | 'brand' | 'wall' | 'prompts' | 'contact' | 'creds'
  | 'engine' | 'misc' | 'versions'
  • Step 2: 重写导航模板——<ElCard class="nav-card"> 内的 ElMenu 区改为:
<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 无关);底部操作条:

<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: 写失败测试:

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 之后)插入:
// ---- 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: 创建组件(要点全量):

<!--
  双状态场景卡(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 六类区原样迁入(模板与逻辑不动,仅搬家),外包:

<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):

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' 名单):

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 区块之后)追加新区块:
// ---- 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)
  }
}
// ---- 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
}
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):

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:轻量打磨 + 全局回归

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 顺序收口