Files
ai-kefu-admin/admin/src/views/config/center/WallPane.vue
T
浅念 481243b958 feat: 配置中心-版本历史(列表/两版对比/回滚/组级重置出厂)
Task 7d——Phase 3 配置中心四连收尾:

- API(api/config.ts):fetchConfigVersions / fetchConfigVersionDetail /
  rollbackConfig / resetConfigGroup 四函数补齐(骨架端点与类型已就位;
  响应形态对照 config_routes 实测——列表无 config 本体、详情整份+掩码、
  reset 响应与 PUT 分组保存同形)
- VersionsPane:版本 tab 实装(导航占位转正)——列表倒序(版本/时间/
  作者/备注,当前版标记)+ 详情抽屉整份 JSON;勾两版(限两个,第三个
  自动撤销最早勾选行)并排等宽 JSON 对比(spec §7.3 最低形态,范围
  下拉可聚焦单组——wall 20KB+ 场景);回滚确认框明示「将产生新版本,
  历史不变」→ 响应内新快照直接回写页头版本/快照/品牌表单,无需再 GET
- 组级重置出厂:六个已实装编辑卡(品牌/提示词/词表/邮件/推送/AI)头部
  危险色小按钮 → reset.ts 共用确认入口(明示组名与「恢复出厂档案值」)
  → POST config/{group}/reset → 表单/密钥态/版本刷新(RESET_GROUP_
  LABELS 九组镜像后端 CONFIG_GROUPS,含待实装的联系方式/引擎/杂项)
- 测试:vitest 120(+13——API 四函数调用形态 5 条 + model 辅助 8 条);
  后端不动 595;冒烟 jiancai:v1-v10 列表 → v1/v10 并排对比(push 组
  __SET__ 掩码可见)→ 回滚 v1 产 v11 且品牌回 v1 值 → 改占位存 v12 →
  重置品牌产 v13 表单回档案值 → 回滚 v10 复原现场(v14)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 21:39:16 +08:00

968 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
安全词表编辑 + 试金石(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 提示
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → 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>
<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'
import { confirmAndResetGroup } from './reset'
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
}
}
// ---- 重置出厂(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
}
}
</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);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
}
.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>