2026-10-13 20:05:42 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
安全词表编辑 + 试金石(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 等)保存时原样透传
|
|
|
|
|
|
- 试金石面板:句子列表(可增删,默认 3 示例句)→ POST wall/test(当前
|
|
|
|
|
|
表单值作 wall 覆盖 = 不落库预演)→ 逐句结果:pass 绿 / blocked 红 /
|
|
|
|
|
|
replaced 橙 + 替换文案;has_contact / source 开关影响再索要墙与替换句
|
|
|
|
|
|
- 保存链:保存词表前自动先跑一次试金石(当前表单值预演)——有
|
|
|
|
|
|
blocked/replaced 时二次确认「判定符合预期再保存」;PUT config/wall
|
|
|
|
|
|
整组提交;正则编译错误 400 的字段路径展示在内联错误区;版本 +1 提示
|
2026-10-13 21:39:16 +08:00
|
|
|
|
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/wall/reset
|
|
|
|
|
|
→ emit saved 回写快照组 → watch 重建编辑模型 = 出厂档案墙
|
2026-10-13 20:05:42 +08:00
|
|
|
|
-->
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="wall-pane">
|
|
|
|
|
|
<ElCard shadow="never" class="form-card">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<span class="card-title">安全词表(wall 组 · 整组替换)</span>
|
2026-10-13 21:39:16 +08:00
|
|
|
|
<div class="header-side">
|
|
|
|
|
|
<span class="card-sub">保存即写新版本并热生效;保存前自动跑试金石</span>
|
|
|
|
|
|
<ElButton
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
plain
|
|
|
|
|
|
:loading="resetting"
|
|
|
|
|
|
@click="onReset"
|
|
|
|
|
|
>
|
|
|
|
|
|
重置出厂
|
|
|
|
|
|
</ElButton>
|
|
|
|
|
|
</div>
|
2026-10-13 20:05:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<ElAlert
|
|
|
|
|
|
v-if="actionError"
|
|
|
|
|
|
type="error"
|
|
|
|
|
|
:closable="true"
|
|
|
|
|
|
class="pane-error"
|
|
|
|
|
|
:title="actionError"
|
|
|
|
|
|
@close="actionError = ''"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<ElEmpty
|
|
|
|
|
|
v-if="!model"
|
|
|
|
|
|
description="当前租户快照无 wall 组——词表随租户 seed(wall.json)创建,缺失时无从结构化编辑"
|
|
|
|
|
|
: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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 单正则区: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 }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</ElCollapseItem>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 语言池区:四池 -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<ElInput
|
|
|
|
|
|
v-model="model.ccTemplates[group][lang]"
|
|
|
|
|
|
placeholder="模板文本"
|
|
|
|
|
|
class="pool-input"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</ElCollapseItem>
|
|
|
|
|
|
</ElCollapse>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 保存条(词表备注可选,与品牌组一致) -->
|
|
|
|
|
|
<div v-if="model" class="save-bar">
|
|
|
|
|
|
<ElInput
|
|
|
|
|
|
v-model="saveComment"
|
|
|
|
|
|
maxlength="200"
|
|
|
|
|
|
placeholder="可选;写入版本历史 comment"
|
|
|
|
|
|
class="comment-input"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<ElButton
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
:loading="saving"
|
|
|
|
|
|
:disabled="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)">
|
|
|
|
|
|
删
|
|
|
|
|
|
</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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { putConfigGroup, testWall, type WallTestResponse } from '@/api/config'
|
|
|
|
|
|
import {
|
|
|
|
|
|
CC_TEMPLATE_VARIANTS,
|
|
|
|
|
|
DEFAULT_TOUCHSTONE_SENTENCES,
|
|
|
|
|
|
NO_CONTACT_KEYS,
|
|
|
|
|
|
POOL_FIELDS,
|
|
|
|
|
|
POOL_REQUIRED_LANGS,
|
|
|
|
|
|
REMINDER_VARIANTS,
|
|
|
|
|
|
buildWallPayload,
|
|
|
|
|
|
initWallEdit,
|
|
|
|
|
|
touchstoneSentences,
|
|
|
|
|
|
type PoolField,
|
|
|
|
|
|
type WallEditModel
|
|
|
|
|
|
} from './model'
|
2026-10-13 21:39:16 +08:00
|
|
|
|
import { confirmAndResetGroup } from './reset'
|
2026-10-13 20:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'WallPane' })
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
/** 租户 slug(保存/试金石目标) */
|
|
|
|
|
|
slug: string
|
|
|
|
|
|
/** GET config 回显的 wall 组(原样快照;null = 缺失/未加载) */
|
|
|
|
|
|
group: Record<string, unknown> | null | undefined
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
(e: 'saved', payload: { version: number; group: 'wall'; config: Record<string, unknown> }): void
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 编辑态(group 引用变化 = 租户切换/重载 → 重建模型) ----------------------------
|
|
|
|
|
|
// 注:watch immediate 在 setup 期同步跑,回调触达的全部状态必须先声明。
|
|
|
|
|
|
|
|
|
|
|
|
const model = ref<WallEditModel | null>(null)
|
|
|
|
|
|
const saveComment = ref('')
|
|
|
|
|
|
const actionError = ref('')
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const testing = ref(false)
|
|
|
|
|
|
const testResults = ref<WallTestResponse['results'] | null>(null)
|
|
|
|
|
|
const lastWallSource = ref<WallTestResponse['wall_source'] | ''>('')
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.group,
|
|
|
|
|
|
(group) => {
|
|
|
|
|
|
model.value = initWallEdit(group)
|
|
|
|
|
|
saveComment.value = ''
|
|
|
|
|
|
actionError.value = ''
|
|
|
|
|
|
testResults.value = null
|
|
|
|
|
|
},
|
|
|
|
|
|
{ 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 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 试金石 ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const sentences = ref<string[]>([...DEFAULT_TOUCHSTONE_SENTENCES])
|
|
|
|
|
|
const hasContact = ref(false)
|
|
|
|
|
|
const source = ref<'web' | 'app'>('web')
|
|
|
|
|
|
|
|
|
|
|
|
function actionTagType(action: string): 'success' | 'danger' | 'warning' {
|
|
|
|
|
|
return action === 'pass' ? 'success' : action === 'blocked' ? 'danger' : 'warning'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function actionLabel(action: string): string {
|
|
|
|
|
|
return action === 'pass' ? 'pass 放行' : action === 'blocked' ? 'blocked 拦截' : 'replaced 替换'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reasonLabel(reason: string): string {
|
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
|
boundary: '价格墙 boundary',
|
|
|
|
|
|
no_contact: '零号码墙 no_contact',
|
|
|
|
|
|
reask: '再索要墙 reask(丢弃,无替换)'
|
|
|
|
|
|
}
|
|
|
|
|
|
return map[reason] ?? reason
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 跑试金石(wall 覆盖 = 当前表单值的预演)。返回结果数组;失败返回 null。 */
|
|
|
|
|
|
async function postTouchstone(): Promise<WallTestResponse['results'] | null> {
|
|
|
|
|
|
if (!model.value) return null
|
|
|
|
|
|
const list = touchstoneSentences(sentences.value)
|
|
|
|
|
|
if (list.length === 0) {
|
|
|
|
|
|
ElMessage.warning('请先填写至少一个试金石句子')
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
actionError.value = ''
|
|
|
|
|
|
testing.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await testWall(
|
|
|
|
|
|
props.slug,
|
|
|
|
|
|
{
|
|
|
|
|
|
sentences: list,
|
|
|
|
|
|
wall: buildWallPayload(model.value),
|
|
|
|
|
|
has_contact: hasContact.value,
|
|
|
|
|
|
source: source.value
|
|
|
|
|
|
},
|
|
|
|
|
|
{ showErrorMessage: false }
|
|
|
|
|
|
)
|
|
|
|
|
|
testResults.value = res.results
|
|
|
|
|
|
lastWallSource.value = res.wall_source
|
|
|
|
|
|
return res.results
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 坏墙(正则编译失败等)400 带字段路径——内联展示
|
|
|
|
|
|
actionError.value = error instanceof Error ? error.message : '试金石调用失败'
|
|
|
|
|
|
testResults.value = null
|
|
|
|
|
|
return null
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
testing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runTouchstone(): Promise<void> {
|
|
|
|
|
|
await postTouchstone()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 保存链:先自动试金石 →(有拦截/替换时二次确认)→ 整组 PUT -----------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function onSave(): Promise<void> {
|
|
|
|
|
|
if (!model.value || saving.value || testing.value) return
|
|
|
|
|
|
actionError.value = ''
|
|
|
|
|
|
const payload = buildWallPayload(model.value)
|
|
|
|
|
|
|
|
|
|
|
|
// 保存前自动预演(有句子才跑;坏墙 400 在此即内联暴露,不会进确认流)
|
|
|
|
|
|
const list = touchstoneSentences(sentences.value)
|
|
|
|
|
|
if (list.length > 0) {
|
|
|
|
|
|
const results = await postTouchstone()
|
|
|
|
|
|
if (results === null) return // 调用失败(含编译错误)——错误已内联展示
|
|
|
|
|
|
const hits = results.filter((r) => r.action !== 'pass')
|
|
|
|
|
|
if (hits.length > 0) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm(
|
|
|
|
|
|
`试金石判定:${hits.length}/${results.length} 句被拦截或替换。` +
|
|
|
|
|
|
'请先核对上方逐句判定符合预期——确认后才写入新版本并热生效。',
|
|
|
|
|
|
'保存前试金石确认',
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
confirmButtonText: '判定符合预期,保存',
|
|
|
|
|
|
cancelButtonText: '回去检查'
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return // 用户取消——回去检查判定
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await putConfigGroup(
|
|
|
|
|
|
props.slug,
|
|
|
|
|
|
'wall',
|
|
|
|
|
|
payload,
|
|
|
|
|
|
saveComment.value.trim() || undefined,
|
|
|
|
|
|
{ showErrorMessage: false }
|
|
|
|
|
|
)
|
|
|
|
|
|
saveComment.value = ''
|
|
|
|
|
|
ElMessage.success(`词表已保存(版本 v${res.version},热生效)`)
|
|
|
|
|
|
emit('saved', { version: res.version, group: 'wall', config: res.config })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 正则编译错误等 400 的字段路径(wall_schema 报错形态如
|
|
|
|
|
|
// "price_patterns[3]: regex 编译失败: ...")内联展示
|
|
|
|
|
|
actionError.value = error instanceof Error ? error.message : '保存失败'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-13 21:39:16 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 重置出厂(Task 7d:确认 → POST reset → emit saved 回写快照组 →
|
|
|
|
|
|
// group 引用变化触发上方 watch 重建编辑模型 = 出厂档案墙) --------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const resetting = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
async function onReset(): Promise<void> {
|
|
|
|
|
|
if (resetting.value) return
|
|
|
|
|
|
resetting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await confirmAndResetGroup(props.slug, 'wall')
|
|
|
|
|
|
if (!res) return // 用户取消确认
|
|
|
|
|
|
ElMessage.success(`词表已重置出厂(版本 v${res.version},热生效)`)
|
|
|
|
|
|
emit('saved', { version: res.version, group: 'wall', config: res.config })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
actionError.value = error instanceof Error ? error.message : '重置失败'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
resetting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-13 20:05:42 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.wall-pane {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-card,
|
|
|
|
|
|
.touchstone-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);
|
|
|
|
|
|
}
|
2026-10-13 21:39:16 +08:00
|
|
|
|
|
|
|
|
|
|
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
|
|
|
|
|
|
.header-side {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
2026-10-13 20:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pane-error {
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-note {
|
|
|
|
|
|
margin: 0 0 10px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
color: var(--art-gray-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 正则行 -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
.regex-row {
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.regex-line {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
.row-index {
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--art-gray-400);
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pattern-input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.flags-select {
|
|
|
|
|
|
width: 130px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-input {
|
|
|
|
|
|
margin: 4px 0 0 26px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
|
|
|
|
|
|
.pool-input {
|
|
|
|
|
|
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;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
.ts-switch {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
.ts-label {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-source {
|
|
|
|
|
|
width: 170px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-run {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-results {
|
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-result {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
padding: 8px 10px;
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: var(--art-main-bg-color, var(--el-fill-color-lighter));
|
|
|
|
|
|
|
|
|
|
|
|
&.ts-blocked {
|
|
|
|
|
|
background: var(--el-color-danger-light-9);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.ts-replaced {
|
|
|
|
|
|
background: var(--el-color-warning-light-9);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.ts-pass {
|
|
|
|
|
|
background: var(--el-color-success-light-9);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-tag {
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-body {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
|
|
|
|
|
|
.ts-sentence,
|
|
|
|
|
|
.ts-reason,
|
|
|
|
|
|
.ts-replacement {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-reason {
|
|
|
|
|
|
color: var(--art-gray-500);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ts-replacement {
|
|
|
|
|
|
color: var(--el-color-warning);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|