feat(admin): WallPane 重组为场景卡+高级折叠(Batch 2 收口)

- 主面换 ConfigCard 卡壳:WALL_SCENARIOS 双状态场景卡 + no_contact 三键
  白话行编辑 + cc_templates 四组封闭语言集 + 试金石面板(整块平移)
- 高级区 WallAdvanced 挂载(正则/单正则/reminders,默认折叠);旧池区/
  正则区/提醒区/旧 collapse 结构与失效辅助全部删除(grep 零残留)
- 保存链加模板变量保护:collectRemovedWallVariables 集差检出被删的
  {intl}/{shown} 等运行期占位符 → ElMessageBox 二次确认(先于试金石)
- emits 追加 navigate;index.vue WallPane 挂载处接线 @navigate
- ScenarioCard 评审折入:requiredMissing 收紧(空白行保留但两池全空的
  必留语言也预警);× 删除钮键盘可达(role/tabindex/enter/space);
  addLang 严格正则注记(异常存量语言码可显示可删不可重加)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 19:34:31 +08:00
parent 0553ccc32c
commit 12b8dab5f0
3 changed files with 250 additions and 594 deletions
+239 -591
View File
@@ -1,40 +1,30 @@
<!--
安全词表编辑 + 试金石(Phase 3 Task 7b;消费 config/center/model.ts 纯逻辑)
- 结构化但务实:按 wall.json 结构分组(ElCollapse 可折叠区块),正则列表
用可增删行编辑器(每条一行 + flags 标记 + comment 显示/编辑;双形态:
纯字符串与 {pattern, flags, comment});语言池按语言增删句子行;
no_contact 三句 / triggers·zero_number 单正则 / reminders 六类话术 /
cc_templates 四组语言模板;未接管的顶层键(_meta 等)保存时原样透传
安全词表编辑 + 试金石(Phase 3 Task 7b 起;Task 10 重组为场景卡形态)
- 主面 = 客户视角场景块:两张双状态场景卡(WallScenarioCard:问价格 /
转向要联系方式 × 未留资/已留资两池位,语言 tabs 增删)+ no_contact
三句 + cc_templates 四组模板 + 试金石面板;未接管的顶层键保存时透传
- 高级折叠区(WallAdvanced):价格/追问识别正则(双形态行编辑)、
触发词与零号码单正则、reminders 六类 AI 指令——默认全部折叠
- 试金石面板:句子列表(可增删,默认 3 示例句)→ POST wall/test(当前
表单值作 wall 覆盖 = 不落库预演)→ 逐句结果:pass 绿 / blocked 红 /
replaced 橙 + 替换文案;has_contact / source 开关影响再索要墙与替换句
- 保存链:保存词表前自动先跑一次试金石(当前表单值预演)——有
blocked/replaced 时二次确认「判定符合预期再保存」;PUT config/wall
整组提交;正则编译错误 400 的字段路径展示在内联错误区;版本 +1 提示
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/wall/reset
- 保存链:模板变量保护({intl}/{shown} 等运行期占位符被删 → 二次确认)
→ 保存前自动先跑一次试金石——有 blocked/replaced 时二次确认「判定
符合预期再保存」→ PUT config/wall 整组提交;正则编译错误 400 的字段
路径展示在内联错误区;版本 +1 提示
- 重置出厂(Task 7d):danger 行小按钮 → 确认框 → POST config/wall/reset
→ emit saved 回写快照组 → watch 重建编辑模型 = 出厂档案墙
-->
<template>
<div class="wall-pane">
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">安全词表(wall 组 · 整组替换)</span>
<div class="header-side">
<span class="card-sub">保存即写新版本并热生效;保存前自动跑试金石</span>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂
</ElButton>
</div>
</div>
</template>
<ConfigCard
class="wall-form-card"
icon="solar:chat-round-line-duotone"
title="AI 话术"
subtitle="AI 回复客户时用的标准句与引擎规则——改动保存后立即对线上对话生效"
info="保存前自动跑试金石预演;正则与 AI 指令在下方「高级」区"
danger-label="恢复出厂:把话术与规则重置为初始档案值"
>
<ElAlert
v-if="actionError"
type="error"
@@ -50,282 +40,156 @@
:image-size="80"
/>
<ElCollapse v-else v-model="openSections" class="wall-collapse">
<!-- 正则列表区:价格墙 / 再索要墙 -->
<ElCollapseItem
v-for="section in patternListSections"
:key="section.key"
:name="section.key"
:title="section.title"
>
<p class="section-note">{{ section.note }}</p>
<div
v-for="(row, i) in section.rows.value"
:key="i"
class="regex-row"
>
<div class="regex-line">
<span class="row-index">{{ i + 1 }}</span>
<ElInput
v-model="row.pattern"
placeholder="正则表达式(Python re 语法)"
class="mono-input pattern-input"
/>
<ElSelect v-model="row.flags" class="flags-select" title="标志位">
<ElOption value="" label="无" />
<ElOption value="i" label="i 忽略大小写" />
</ElSelect>
<ElButton
:type="row.comment ? 'primary' : 'default'"
link
title="设计备注(comment,随词表保存但不参与编译)"
@click="row.showComment = !row.showComment"
>
备注{{ row.comment ? '●' : '○' }}
</ElButton>
<ElButton
type="danger"
link
title="删除此条"
@click="section.remove(i)"
>
删
</ElButton>
</div>
<ElInput
v-if="row.showComment"
v-model="row.comment"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="comment:设计说明(防误杀断言等),保存为条目 comment 字段"
class="comment-input"
/>
<p v-else-if="row.comment" class="comment-view mono">{{ row.comment }}</p>
</div>
<ElButton size="small" @click="section.add()">+ 添加一条</ElButton>
</ElCollapseItem>
<template v-else>
<!-- 1. 双状态场景卡:问价格 / 转向要联系方式(× 未留资/已留资两池位) -->
<WallScenarioCard
v-for="s in WALL_SCENARIOS"
:key="s.key"
class="wp-section"
:def="s"
:model="model"
/>
<!-- 单正则区:triggers 三键 / zero_number 两键 -->
<ElCollapseItem
v-for="section in singlePatternSections"
:key="section.key"
:name="section.key"
:title="section.title"
>
<p class="section-note">{{ section.note }}</p>
<div class="regex-row">
<div class="regex-line">
<ElInput
v-model="model.singles[section.key].pattern"
placeholder="正则表达式(Python re 语法)"
class="mono-input pattern-input"
/>
<ElSelect
v-model="model.singles[section.key].flags"
class="flags-select"
title="标志位"
>
<ElOption value="" label="无" />
<ElOption value="i" label="i 忽略大小写" />
</ElSelect>
</div>
<p
v-if="model.singles[section.key].comment"
class="comment-view mono"
>
{{ model.singles[section.key].comment }}
<!-- 2. no_contact 三句(号码/邮箱红线命中时的整句替换) -->
<div class="mini-card wp-section">
<div class="mini-head">
<p class="mini-title">号码/邮箱被拦时的替换句</p>
<p class="mini-note">
客户消息命中号码红线时 AI 整句替换为对应句——按会话来源选 web / app;已留资后再命中用 already
</p>
</div>
</ElCollapseItem>
<div v-for="key in NO_CONTACT_KEYS" :key="key" class="mini-row">
<ElTag size="small" type="info" class="mini-tag">{{ key }}</ElTag>
<span class="mini-row-label">{{ NO_CONTACT_LABELS[key] }}</span>
<ElInput v-model="model.noContact[key]" placeholder="替换句" class="mini-input" />
</div>
</div>
<!-- 语言池区:四池 -->
<ElCollapseItem
v-for="field in POOL_FIELDS"
:key="field"
:name="`pool-${field}`"
:title="POOL_META[field].title"
>
<p class="section-note">{{ POOL_META[field].note }}</p>
<div v-for="(rows, lang) in model.pools[field]" :key="lang" class="pool-lang">
<div class="pool-lang-head">
<ElTag size="small" type="info">{{ langLabel(lang) }}</ElTag>
<span v-if="isPoolRequiredLang(lang)" class="lang-required">
必填语言(en/zh 为编译必需,不可删除)
</span>
<ElButton
v-else
type="danger"
link
size="small"
@click="removePoolLang(field, lang)"
>
删除语言
</ElButton>
</div>
<div v-for="(row, i) in rows" :key="i" class="pool-row">
<ElInput v-model="rows[i]" placeholder="话术句子" class="pool-input" />
<ElButton type="danger" link title="删除此句" @click="rows.splice(i, 1)">
删
</ElButton>
</div>
<ElButton size="small" class="pool-add" @click="rows.push('')">
+ 添加一句
</ElButton>
<!-- 3. cc_templates 四组 × 封闭语言集 -->
<div class="mini-card wp-section">
<div class="mini-head">
<p class="mini-title">国家代码模板</p>
<p class="mini-note">{intl} / {cc_script} 为运行期回填变量,请勿删除</p>
</div>
<div v-if="Object.keys(model.pools[field]).length === 0" class="pool-empty">
该池暂无语言——添加必填语言 en / zh 后才能通过编译校验
</div>
<div class="pool-new-lang">
<ElInput
v-model="newLangInputs[field]"
placeholder="语言代码(如 es / pt / ru)"
class="new-lang-input"
@keyup.enter="addPoolLang(field)"
/>
<ElButton size="small" @click="addPoolLang(field)">+ 添加语言</ElButton>
</div>
</ElCollapseItem>
<!-- no_contact 三句 -->
<ElCollapseItem name="no_contact" title="无联系方式应答 no_contact(web / app / already 三句)">
<p class="section-note">
零号码墙/索要联系方式时的替换句:web 指页面按钮 / app 无按钮引导留资 / already 已留资不再索要
</p>
<div v-for="key in NO_CONTACT_KEYS" :key="key" class="pool-row">
<ElTag size="small" type="info" class="nc-tag">{{ key }}</ElTag>
<ElInput v-model="model.noContact[key]" placeholder="替换句" class="pool-input" />
</div>
</ElCollapseItem>
<!-- reminders 六类话术 -->
<ElCollapseItem name="reminders" title="轮中提醒 reminders(六类话术,{shown}/{intl} 等占位符由系统回填)">
<div v-for="(variants, cat) in REMINDER_VARIANTS" :key="cat" class="reminder-cat">
<p class="reminder-cat-title">{{ reminderCatLabel(cat) }}</p>
<div v-for="variant in variants" :key="variant" class="reminder-variant">
<span class="variant-label mono">{{ cat }}.{{ variant }}</span>
<ElInput
v-model="model.reminders[cat][variant]"
type="textarea"
:autosize="{ minRows: 2, maxRows: 8 }"
:placeholder="variantLabel(cat, variant)"
/>
</div>
</div>
</ElCollapseItem>
<!-- cc_templates 四组语言模板 -->
<ElCollapseItem name="cc_templates" title="国家代码模板 cc_templates(四组 × 语言;{intl}/{cc_script} 占位符)">
<div v-for="(langs, group) in CC_TEMPLATE_VARIANTS" :key="group" class="reminder-cat">
<p class="reminder-cat-title">{{ ccGroupLabel(group) }}</p>
<div v-for="lang in langs" :key="lang" class="pool-row">
<ElTag size="small" type="info" class="nc-tag">{{ lang }}</ElTag>
<div v-for="(langs, group) in CC_TEMPLATE_VARIANTS" :key="group" class="cc-group">
<p class="cc-group-title">{{ CC_GROUP_LABELS[group] ?? group }}</p>
<div v-for="lang in langs" :key="lang" class="mini-row">
<ElTag size="small" type="info" class="mini-tag">{{ lang }}</ElTag>
<ElInput
v-model="model.ccTemplates[group][lang]"
placeholder="模板文本"
class="pool-input"
class="mini-input"
/>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</div>
<!-- 保存条(词表备注可选,与品牌组一致) -->
<div v-if="model" class="save-bar">
<!-- 4. 试金石(不落库预演) -->
<div class="mini-card wp-section">
<div class="mini-head">
<p class="mini-title">试金石(不落库预演)</p>
<p class="mini-note">用当前表单值逐句判定——保存前会自动再跑一次</p>
</div>
<p class="section-note">
判定优先级与线上执行一致:价格墙(拦截+替换)→ 零号码墙(替换)→
再索要墙(仅客户已留资时丢弃)。改完词表先在这里验判定再保存。
</p>
<div class="ts-controls">
<div class="ts-switch">
<span class="ts-label">客户已留联系方式</span>
<ElSwitch v-model="hasContact" />
</div>
<div class="ts-switch">
<span class="ts-label">会话来源</span>
<ElSelect v-model="source" class="ts-source">
<ElOption value="web" label="web(网页挂件)" />
<ElOption value="app" label="app(无页面按钮)" />
</ElSelect>
</div>
</div>
<div v-for="(s, i) in sentences" :key="i" class="pool-row">
<ElInput
v-model="sentences[i]"
placeholder="待判定句子(AI 输出侧视角)"
class="pool-input"
/>
<ElButton type="danger" link title="删除此句" @click="sentences.splice(i, 1)">
删
</ElButton>
</div>
<ElButton size="small" class="pool-add" @click="sentences.push('')">
+ 添加一句
</ElButton>
<div class="ts-run">
<ElButton type="primary" :loading="testing" @click="runTouchstone()">
跑一次试金石(当前表单值)
</ElButton>
<ElTag v-if="lastWallSource" size="small" type="info">
{{ lastWallSource === 'override' ? '当前表单值预演' : '当前生效墙' }}
</ElTag>
</div>
<div v-if="testResults" class="ts-results">
<div
v-for="(r, i) in testResults"
:key="i"
class="ts-result"
:class="`ts-${r.action}`"
>
<ElTag :type="actionTagType(r.action)" size="small" class="ts-tag">
{{ actionLabel(r.action) }}
</ElTag>
<div class="ts-body">
<p class="ts-sentence">{{ r.sentence }}</p>
<p v-if="r.reason" class="ts-reason">
命中:{{ reasonLabel(r.reason) }}
</p>
<p v-if="r.replacement" class="ts-replacement">
替换文案:{{ r.replacement }}
</p>
</div>
</div>
</div>
</div>
<!-- 5. 高级折叠区:正则与 AI 指令(默认折叠) -->
<div class="advanced-block">
<p class="advanced-title">高级:正则与 AI 指令</p>
<WallAdvanced :model="model" />
</div>
</template>
<template #footer-left>
<ElButton link type="primary" size="small" @click="emit('navigate', 'versions')">
出问题?回滚 →
</ElButton>
</template>
<template #footer-right>
<ElInput
v-model="saveComment"
maxlength="200"
placeholder="可选;写入版本历史 comment"
placeholder="备注(选填)"
class="comment-input"
/>
<ElButton
type="primary"
:loading="saving"
:disabled="testing"
:disabled="!model || testing"
@click="onSave"
>
保存词表组(先自动试金石,再整组提交)
保存
</ElButton>
</div>
</ElCard>
<!-- 试金石面板 -->
<ElCard shadow="never" class="touchstone-card">
<template #header>
<div class="card-header">
<span class="card-title">试金石(wall/test · 不落库预演)</span>
<span class="card-sub">用当前表单值逐句判定——保存前会自动再跑一次</span>
</div>
</template>
<p class="section-note">
判定优先级与线上执行一致:价格墙(拦截+替换)→ 零号码墙(替换)→
再索要墙(仅客户已留资时丢弃)。改完词表先在这里验判定再保存。
</p>
<div class="ts-controls">
<div class="ts-switch">
<span class="ts-label">客户已留联系方式</span>
<ElSwitch v-model="hasContact" />
</div>
<div class="ts-switch">
<span class="ts-label">会话来源</span>
<ElSelect v-model="source" class="ts-source">
<ElOption value="web" label="web(网页挂件)" />
<ElOption value="app" label="app(无页面按钮)" />
</ElSelect>
</div>
</div>
<div v-for="(s, i) in sentences" :key="i" class="pool-row">
<ElInput
v-model="sentences[i]"
placeholder="待判定句子(AI 输出侧视角)"
class="pool-input"
/>
<ElButton type="danger" link title="删除此句" @click="sentences.splice(i, 1)">
删
<template #danger>
<ElButton size="small" type="danger" plain :loading="resetting" @click="onReset">
重置出厂…
</ElButton>
</div>
<ElButton size="small" class="pool-add" @click="sentences.push('')">
+ 添加一句
</ElButton>
<div class="ts-run">
<ElButton
type="primary"
:loading="testing"
:disabled="!model"
@click="runTouchstone()"
>
跑一次试金石(当前表单值)
</ElButton>
<ElTag v-if="lastWallSource" size="small" type="info">
{{ lastWallSource === 'override' ? '当前表单值预演' : '当前生效墙' }}
</ElTag>
</div>
<div v-if="testResults" class="ts-results">
<div
v-for="(r, i) in testResults"
:key="i"
class="ts-result"
:class="`ts-${r.action}`"
>
<ElTag :type="actionTagType(r.action)" size="small" class="ts-tag">
{{ actionLabel(r.action) }}
</ElTag>
<div class="ts-body">
<p class="ts-sentence">{{ r.sentence }}</p>
<p v-if="r.reason" class="ts-reason">
命中:{{ reasonLabel(r.reason) }}
</p>
<p v-if="r.replacement" class="ts-replacement">
替换文案:{{ r.replacement }}
</p>
</div>
</div>
</div>
</ElCard>
</template>
</ConfigCard>
</div>
</template>
@@ -336,16 +200,17 @@
CC_TEMPLATE_VARIANTS,
DEFAULT_TOUCHSTONE_SENTENCES,
NO_CONTACT_KEYS,
POOL_FIELDS,
POOL_REQUIRED_LANGS,
REMINDER_VARIANTS,
WALL_SCENARIOS,
buildWallPayload,
collectRemovedWallVariables,
initWallEdit,
touchstoneSentences,
type PoolField,
type WallEditModel
} from './model'
import { confirmAndResetGroup } from './reset'
import ConfigCard from './ConfigCard.vue'
import WallScenarioCard from './wall/ScenarioCard.vue'
import WallAdvanced from './wall/WallAdvanced.vue'
defineOptions({ name: 'WallPane' })
@@ -358,6 +223,8 @@
const emit = defineEmits<{
(e: 'saved', payload: { version: number; group: 'wall'; config: Record<string, unknown> }): void
/** footer 回滚链接:请求父层切到版本历史组 */
(e: 'navigate', group: string): void
}>()
// ---- 编辑态(group 引用变化 = 租户切换/重载 → 重建模型) ----------------------------
@@ -382,167 +249,19 @@
{ immediate: true }
)
// ---- 区块展开态(默认收起——词表区块多,按需展开) -----------------------------------
// ---- 主面小卡白话标签 ------------------------------------------------------------------
const openSections = ref<string[]>([])
// ---- 正则列表区(价格墙 / 再索要墙) -------------------------------------------------
const patternListSections = computed(() => [
{
key: 'price_patterns',
title: '价格墙 price_patterns(正则列表,命中即整句拦截)',
note: '每条一行;flags 仅支持 i(忽略大小写);comment 为设计说明(不参与编译)。空 pattern 行保存时自动剔除。',
rows: toRef(() => model.value?.priceRows ?? []),
add: () => model.value?.priceRows.push(blankRegexRow()),
remove: (i: number) => model.value?.priceRows.splice(i, 1)
},
{
key: 'reask_patterns',
title: '再索要墙 reask_patterns(正则列表,仅客户已留资时启用)',
note: 'AI 输出侧防重复索要:命中且客户已留联系方式 → 静默丢弃该句(无替换文案)。',
rows: toRef(() => model.value?.reaskRows ?? []),
add: () => model.value?.reaskRows.push(blankRegexRow()),
remove: (i: number) => model.value?.reaskRows.splice(i, 1)
}
])
function blankRegexRow() {
return { pattern: '', flags: '' as const, comment: '', showComment: true }
}
// ---- 单正则区(triggers / zero_number) ----------------------------------------------
const singlePatternSections = [
{
key: 'boundary',
title: '边界触发器 triggers.boundary',
note: '客户侧触发词大表(价格/折扣/交期/样品等)——命中注入边界提醒话术。'
},
{
key: 'budget',
title: '预算触发器 triggers.budget',
note: '客户报预算触发词(与 reminders.boundary.budget 话术联动)。'
},
{
key: 'contact_ask',
title: '索要联系方式触发器 triggers.contact_ask',
note: '客户索要我方联系方式(最高意向信号)——命中注入对应 source 话术。'
},
{
key: 'long_number',
title: '零号码墙 zero_number.long_number',
note: '≥10 位数字串(号码红线)——命中整句替换为 no_contact 对应句。'
},
{
key: 'any_email',
title: '零号码墙 zero_number.any_email',
note: '邮箱地址(号码红线同款)——命中整句替换。'
}
] as const
// ---- 语言池区 -------------------------------------------------------------------------
const POOL_META: Record<PoolField, { title: string; note: string }> = {
deflections: {
title: '迂回话术池 deflections($DEFLECTION_POOL 注入源)',
note: '价格墙命中后的替换句池(按客户语言取句轮换)——system.md 的 $DEFLECTION_POOL 占位符注入的正是本池。'
},
deflections_contact: {
title: '已留资话术池 deflections_contact',
note: '客户已留联系方式时的价格墙替换句池(不得再索要——与再索要墙联动)。'
},
pivots: {
title: '转向话术池 pivots',
note: '句子粒度门控的过渡收尾句(前句干净已流出时接一句转向)。'
},
pivots_contact: {
title: '已留资转向话术池 pivots_contact',
note: '客户已留联系方式时的过渡收尾句池。'
}
}
const newLangInputs = reactive(
Object.fromEntries(POOL_FIELDS.map((f) => [f, ''])) as Record<PoolField, string>
)
const LANG_LABELS: Record<string, string> = {
en: '英语 en',
zh: '中文 zh',
es: '西语 es',
pt: '葡语 pt',
ru: '俄语 ru',
ar: '阿语 ar',
fr: '法语 fr',
de: '德语 de'
}
function langLabel(lang: string): string {
return LANG_LABELS[lang] ?? lang
}
function isPoolRequiredLang(lang: string): boolean {
return (POOL_REQUIRED_LANGS as readonly string[]).includes(lang)
}
function addPoolLang(field: PoolField): void {
const lang = newLangInputs[field].trim().toLowerCase()
if (!/^[a-z][a-z0-9_-]{0,15}$/.test(lang)) {
ElMessage.warning('语言代码须为 2-16 位小写字母开头(如 es / pt)')
return
}
if (!model.value) return
if (model.value.pools[field][lang]) {
ElMessage.warning(`语言 ${lang} 已存在`)
return
}
model.value.pools[field][lang] = ['']
newLangInputs[field] = ''
}
function removePoolLang(field: PoolField, lang: string): void {
if (isPoolRequiredLang(lang)) {
ElMessage.warning('en / zh 是编译必填语言,不可删除')
return
}
delete model.value?.pools[field][lang]
}
// ---- 提醒/模板标签 --------------------------------------------------------------------
const REMINDER_CAT_LABELS: Record<string, string> = {
boundary: '职能边界',
contact_ask: '索要联系方式',
language_follow: '语言跟随',
lead_given: '已留线索',
cc_guide: '国家代码指引',
email_light: '邮箱轻补'
}
function reminderCatLabel(cat: string): string {
return REMINDER_CAT_LABELS[cat] ?? cat
}
function variantLabel(cat: string, variant: string): string {
const map: Record<string, string> = {
'boundary.budget': '客户报预算求推荐的边界提醒',
'boundary.normal': '常规价格/交期询问的边界提醒',
'contact_ask.app_already': 'App 会话且已留资',
'contact_ask.app': 'App 会话(无页面按钮)',
'contact_ask.web': 'Web 会话(指路页面按钮)'
}
return map[`${cat}.${variant}`] ?? variant
const NO_CONTACT_LABELS: Record<(typeof NO_CONTACT_KEYS)[number], string> = {
web: '网页会话(页面有按钮)',
app: 'App 会话(无页面按钮)',
already: '客户已留过联系方式时'
}
const CC_GROUP_LABELS: Record<string, string> = {
ask: 'ask(无法补全时询问)',
wall: 'wall(墙替换时询问)',
note: 'note(轻补说明,en/zh)',
echo: 'echo(回显确认 {intl})'
}
function ccGroupLabel(group: string): string {
return CC_GROUP_LABELS[group] ?? group
ask: '请客户重发完整号码',
wall: '拦截后请重发',
note: '轻补指引(AI 指令)',
echo: '确认回显'
}
// ---- 试金石 ---------------------------------------------------------------------------
@@ -606,13 +325,24 @@
await postTouchstone()
}
// ---- 保存链:先自动试金石 →(有拦截/替换时二次确认)→ 整组 PUT -----------------------
// ---- 保存链:变量保护 → 先自动试金石 →(有拦截/替换时二次确认)→ 整组 PUT -------------
async function onSave(): Promise<void> {
if (!model.value || saving.value || testing.value) return
actionError.value = ''
const payload = buildWallPayload(model.value)
// 模板变量保护:{intl}/{shown} 等运行期回填占位符在编辑中被删除 → 二次确认
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
}
// 保存前自动预演(有句子才跑;坏墙 400 在此即内联暴露,不会进确认流)
const list = touchstoneSentences(sentences.value)
if (list.length > 0) {
@@ -683,39 +413,12 @@
.wall-pane {
display: flex;
flex: 1;
gap: 16px;
align-items: flex-start;
min-width: 0;
flex-direction: column;
}
.form-card,
.touchstone-card {
.wall-form-card {
width: 100%;
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
.card-title {
font-weight: 600;
}
.card-sub {
font-size: 12px;
color: var(--art-gray-400);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
}
.pane-error {
@@ -729,81 +432,89 @@
color: var(--art-gray-400);
}
// ---- 正则行 -------------------------------------------------------------------------
// ---- 主面小卡(no_contact / cc_templates / 试金石——观感同场景卡) ---------------------
.regex-row {
margin-bottom: 8px;
.wp-section {
margin-bottom: 16px;
}
.regex-line {
display: flex;
gap: 6px;
align-items: center;
.mini-card {
padding: 14px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.row-index {
width: 20px;
.mini-head {
margin-bottom: 10px;
.mini-title {
margin: 0;
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.mini-note {
margin: 2px 0 0;
font-size: 12px;
line-height: 1.6;
color: var(--art-gray-500);
}
}
}
.mini-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
.mini-tag {
width: 52px;
flex-shrink: 0;
font-size: 12px;
color: var(--art-gray-400);
text-align: right;
justify-content: center;
}
.pattern-input {
.mini-row-label {
width: 200px;
flex-shrink: 0;
font-size: 13px;
color: var(--el-text-color-regular);
}
.mini-input {
flex: 1;
min-width: 0;
}
}
.flags-select {
width: 130px;
flex-shrink: 0;
.cc-group {
margin-bottom: 12px;
.cc-group-title {
margin: 0 0 6px;
padding-left: 8px;
font-size: 12px;
color: var(--art-gray-500);
border-left: 3px solid var(--el-color-primary-light-5);
}
}
// ---- 高级折叠区标题 -------------------------------------------------------------------
.advanced-block {
.advanced-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.comment-input {
margin: 4px 0 0 26px;
width: 220px;
}
.comment-view {
margin: 4px 0 0 26px;
padding: 6px 8px;
font-size: 12px;
line-height: 1.6;
color: var(--art-gray-500);
background: var(--art-main-bg-color, var(--el-fill-color-lighter));
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
:deep(.mono-input textarea),
:deep(.mono-input input) {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
// ---- 语言池 -------------------------------------------------------------------------
.pool-lang {
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--el-border-color-lighter);
.pool-lang-head {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 8px;
.lang-required {
font-size: 12px;
color: var(--art-gray-400);
}
}
}
// ---- 试金石 ---------------------------------------------------------------------------
.pool-row {
display: flex;
@@ -815,75 +526,12 @@
flex: 1;
min-width: 0;
}
.nc-tag {
width: 56px;
flex-shrink: 0;
justify-content: center;
}
}
.pool-add {
margin-top: 2px;
}
.pool-empty {
margin-bottom: 10px;
font-size: 12px;
color: var(--el-color-warning);
}
.pool-new-lang {
display: flex;
gap: 8px;
align-items: center;
.new-lang-input {
width: 200px;
}
}
// ---- 提醒 / 模板 ----------------------------------------------------------------------
.reminder-cat {
margin-bottom: 14px;
.reminder-cat-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
}
.reminder-variant {
display: flex;
gap: 8px;
margin-bottom: 8px;
.variant-label {
width: 190px;
flex-shrink: 0;
padding-top: 6px;
font-size: 12px;
color: var(--art-gray-500);
text-align: right;
}
}
.save-bar {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
margin-top: 12px;
.comment-input {
width: 360px;
}
}
// ---- 试金石 ---------------------------------------------------------------------------
.ts-controls {
display: flex;
gap: 20px;
+1
View File
@@ -447,6 +447,7 @@
:slug="activeSlug"
:group="snapshot?.wall"
@saved="onPaneSaved"
@navigate="onGroupSelect"
/>
</div>
</template>
@@ -21,8 +21,11 @@
<template #label>
<span class="sc-lang-label">
{{ lang }}
<span v-if="isExtraLang(lang)" class="sc-lang-del" title="删除此语言"
@click.prevent="removeLang(lang)">×</span>
<span v-if="isExtraLang(lang)" class="sc-lang-del" role="button" tabindex="0"
aria-label="删除此语言" title="删除此语言"
@click.prevent="removeLang(lang)"
@keydown.enter.prevent="removeLang(lang)"
@keydown.space.prevent="removeLang(lang)">×</span>
</span>
</template>
<div v-for="pool in def.pools" :key="pool.field" class="sc-pool">
@@ -84,9 +87,12 @@
{ 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))
(l) =>
!langs.value.includes(l) ||
props.def.pools.some((p) => !(props.model.pools[p.field][l]?.some((t) => t.trim() !== '')))
)
)
@@ -117,6 +123,7 @@
}
function addLang(): void {
// 正则保持严格 ^[a-z]{2,3}$——异常存量语言码可显示、可删、不可重加(Task 10 定稿注记)
const code = newLang.value.trim().toLowerCase()
if (!/^[a-z]{2,3}$/.test(code)) {
ElMessage.warning('语言代码为 2-3 位字母(如 pt / ru / ar)')