From 348e0b55271315b440da3b11621ebab1afeba71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 20 Oct 2026 22:51:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20es=20=E5=B0=BE=E5=B7=B4=E7=A9=BA?= =?UTF-8?q?=3D=E6=95=B4=E9=94=AE=E4=B8=A2=E5=BC=83=EF=BC=88spec=20=C2=A73.?= =?UTF-8?q?2=20=E7=AE=A1=E9=81=93=E8=BA=AB=E4=BB=BD=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核页 es 收尾句留空的原语义「不触 es 池(保持快照原值)」是误读—— spec §3.2 要求整键丢弃:出厂 es 池带行业身份(如建材档案的 tuberías),保留快照会把上一行业的收尾句继续投给本租户的西语客户。 - model.ts:新增 applyWallDispatch 纯函数(wall 保存步载荷落地—— 非 null 语言覆写、null 语言整键删除);wallPoolDispatch/WallDispatch 及三处旧「不触 es 池」注记全部改为整键丢弃语义 - index.vue:wall 步改用 applyWallDispatch;es 输入 placeholder 改 「留空 = 丢弃现有 es 池」;进审核页后台拉取现 es 池,留空且有句时 展示丢弃预览(破坏性操作不静默);skipAvatar 勾选保存中禁用 (防 for-of 期间改动 saveSteps 计算源) - 测试:原「es新句为null_整键删除」用例名实不符(断言的是保快照) 已正名并移交载荷断言;新增 applyWallDispatch 两用例——tuberías 泄漏链回归钉('es' in payload === false)+ 其余语言键透传 后端安全性已核实:boundary_filter 的 pools.get(lang) or pools["en"] 使缺 es 回退到新 EN 尾巴;wall_schema 仅必留 en/zh。 Co-Authored-By: Claude Code --- .../views/site-import/__tests__/model.test.ts | 45 ++++++++- admin/src/views/site-import/index.vue | 96 +++++++++++++++++-- admin/src/views/site-import/model.ts | 34 +++++-- 3 files changed, 155 insertions(+), 20 deletions(-) diff --git a/admin/src/views/site-import/__tests__/model.test.ts b/admin/src/views/site-import/__tests__/model.test.ts index 860efbf..b733154 100644 --- a/admin/src/views/site-import/__tests__/model.test.ts +++ b/admin/src/views/site-import/__tests__/model.test.ts @@ -14,10 +14,14 @@ * 版本含 avatar 文件名,读-改-写才不丢;wall 带池形态分派注记;每步 * 含 label 供进度展示); * - warningsLevel:规格判定=info / 剔除·数字移除=warning / 其他=info; - * - isTerminal404:ApiError status 404 → 终态(任务过期,停止轮询)。 + * - isTerminal404:ApiError status 404 → 终态(任务过期,停止轮询); + * - wallPoolDispatch / applyWallDispatch:池形态分派(单句→替换、多句→ + * 追加)与载荷落地——es 尾巴 null = **整键丢弃** es 池(spec §3.2 防 + * 出厂 es 池行业身份泄漏,如建材档案「tuberías」泄漏给非管道租户)。 */ import { describe, expect, it } from 'vitest' import { + applyWallDispatch, buildSaveSequence, dataUrlToBlob, draftToBrandForm, @@ -233,9 +237,10 @@ describe('wallPoolDispatch', () => { expect(d.pools.es).toEqual(['uno', 'dos', 'tail-es']) }) - it('es新句为null_整键删除', () => { - // es 尾巴缺失 = 合法降级(spec §4):pools.es 为 null——消费方载荷 - // 不触 es 键(保持 GET 快照原值),「整键删除」指分派结果该键为 null + it('es新句为null_分派标记null(payload落地=整键删除,见 applyWallDispatch)', () => { + // es 尾巴缺失 = 合法降级:分派结果 pools.es 为 null——**语义是整键 + // 丢弃**(spec §3.2:防出厂 es 池行业身份泄漏),不是「保持快照原值」。 + // 载荷落地(键真的被删)由 applyWallDispatch 承担并在其用例断言。 const current = { en: ['a'], zh: ['中'], es: ['es-现句'] } const d = wallPoolDispatch(current, { en: 'e', zh: 'z', es: null }) expect(d.mode).toBe('replace') @@ -263,3 +268,35 @@ describe('wallPoolDispatch', () => { expect(d.pools.es).toBeNull() }) }) + +// ---- applyWallDispatch(wall 保存步载荷落地) -------------------------------------------- + +describe('applyWallDispatch', () => { + it('es null = 整键删除(spec §3.2——出厂 es 池行业身份不外漏)', () => { + // 泄漏链回归钉:jiancai 出厂 es 池含「tuberías」(管道)——非管道 + // 租户导入站点无西语内容时,es 键必须从载荷中消失(而非保留快照), + // 否则西语客户继续看到上一行业的收尾句。 + const current = { + en: ['a'], + zh: ['中'], + es: ['Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve. ¿Algo más sobre nuestras tuberías?'] + } + const d = wallPoolDispatch(current, { en: 'e', zh: 'z', es: null }) + const payload = applyWallDispatch(current, d) + // es 键不复存在(不是空数组、不是原句——键本身被删) + expect('es' in payload).toBe(false) + expect(payload).toEqual({ en: ['e'], zh: ['z'] }) + }) + + it('非 null 语言覆写、其余语言键透传(快照 spread)', () => { + const current = { en: ['old-en'], zh: ['旧句一', '旧句二'], fr: ['bonjour'] } + const d = wallPoolDispatch(current, { en: 'new-en', zh: '新句', es: 'nuevo' }) + const payload = applyWallDispatch(current, d) + expect(payload).toEqual({ + en: ['new-en'], + zh: ['旧句一', '旧句二', '新句'], + es: ['nuevo'], + fr: ['bonjour'] // 快照其余语言键原样透传 + }) + }) +}) diff --git a/admin/src/views/site-import/index.vue b/admin/src/views/site-import/index.vue index d69602c..230e839 100644 --- a/admin/src/views/site-import/index.vue +++ b/admin/src/views/site-import/index.vue @@ -17,7 +17,8 @@ - 顶部黄条「保存将覆盖当前配置(产生新版本,可随时回滚)」+ warnings 分级区(warningsLevel:info 蓝 / warning 橙); - 左列品牌表单(draftToBrandForm 预填可改;头像 dataURL 预览 + - 「不上传头像」勾选)+ 墙尾巴 en/zh/es 三行(es 留空 = 不修改该语言) + 「不上传头像」勾选)+ 墙尾巴 en/zh/es 三行(es 留空 = 整键丢弃现有 + es 池——spec §3.2 防出厂行业身份泄漏;现池有句时展示丢弃预览) + 联系方式两开关;右列四件套 tabs(等宽 textarea + UTF-8 字节计数); - 「保存全部」按 buildSaveSequence 依次执行(复用 ConfigCard 视觉壳): 头像(dataUrlToBlob→uploadBrandAvatar)→ brand → prompts → wall → @@ -197,7 +198,11 @@ 无
- + 不上传头像(保留当前头像)

草稿未抓到可用头像——保存不涉及头像

@@ -253,11 +258,25 @@ - +
+ +
+

+ 保存后将丢弃现有 es 池({{ currentEsPool.length }} 句,西语客户将看到上方英文收尾句): +

+
    +
  • {{ s }}
  • +
+
+

- 保存时按当前池形态分派:出厂单句池整句替换;已含人工编辑句(多句)的池追加到末项并提示。 + 保存时按当前池形态分派:出厂单句池整句替换;已含人工编辑句(多句)的池追加到末项并提示。es + 留空 = 整键丢弃现有 es 池(西语会话回退英文收尾句)。

@@ -386,6 +405,7 @@ import ConfigCard from '@/views/config/center/ConfigCard.vue' import { PROMPTS_CAPS, formatKB, utf8Bytes } from '@/views/config/center/model' import { + applyWallDispatch, buildSaveSequence, dataUrlToBlob, draftToBrandForm, @@ -632,6 +652,33 @@ const wallTails = ref({ en: '', zh: '', es: '' }) const contactForm = ref({ ccCompletion: false, emailLightAsk: false }) const skipAvatar = ref(false) + /** 现租户 es 池(进审核页时后台拉取——es 留空的丢弃预览数据源) */ + const currentEsPool = ref([]) + + /** es 留空且现池有句 → 展示将被丢弃的句子(spec §3.2 整键丢弃是破坏性 + * 操作,对多句人工池不静默——预览让管理员看清丢的是什么) */ + const esDropPreview = computed( + () => wallTails.value.es.trim() === '' && currentEsPool.value.length > 0 + ) + + /** 后台拉取现租户 es 池(静默失败为空——预览缺失不阻塞审核);完成后 + * 校验 slug 未变,防跨租户串台(关闭重开的竞态窗口) */ + function loadCurrentEsPool(slug: string): void { + fetchTenantConfig(slug, { showErrorMessage: false }) + .then((snap) => { + if (targetSlug.value !== slug) return + const group = snap.config.wall as Record | undefined + const pools = group?.deflections_contact as Record | undefined + const es = pools?.es + currentEsPool.value = Array.isArray(es) + ? es.filter((s): s is string => typeof s === 'string') + : [] + }) + .catch(() => { + if (targetSlug.value !== slug) return + currentEsPool.value = [] + }) + } function blankBrandForm(): SiteImportBrandForm { return { @@ -656,6 +703,7 @@ wallTails.value = { en: '', zh: '', es: '' } contactForm.value = { ccCompletion: false, emailLightAsk: false } skipAvatar.value = false + currentEsPool.value = [] saveState.value = 'idle' completedKinds.value = [] saveErrorText.value = '' @@ -675,6 +723,7 @@ } importVisible.value = false reviewVisible.value = true + loadCurrentEsPool(targetSlug.value) } function byteInfo(tab: PromptsTab): { over: boolean; label: string } { @@ -746,7 +795,7 @@ return '主题色须为 3 或 6 位十六进制(如 #066fc0),或留空取挂件默认' } if (!wallTails.value.en.trim() || !wallTails.value.zh.trim()) { - return '墙尾巴 en / zh 不能为空(es 可留空 = 不修改该语言池)' + return '墙尾巴 en / zh 不能为空(es 可留空 = 丢弃现有 es 池)' } for (const tab of promptsTabs.value) { if (utf8Bytes(tab.text) > PROMPTS_CAPS[tab.key]) { @@ -814,11 +863,7 @@ zh: wallTails.value.zh.trim(), es: wallTails.value.es.trim() === '' ? null : wallTails.value.es.trim() }) - const nextPool: Record = { ...current } - for (const lang of ['en', 'zh', 'es'] as const) { - const pool = dispatch.pools[lang] - if (pool !== null) nextPool[lang] = pool - } + const nextPool = applyWallDispatch(current, dispatch) await putConfigGroup( slug, 'wall', @@ -1125,6 +1170,37 @@ color: var(--art-gray-400); } + .es-tail-col { + display: flex; + flex-direction: column; + width: 100%; + gap: 6px; + } + + .es-drop-note { + padding: 8px 10px; + font-size: 12px; + line-height: 1.6; + color: var(--el-color-warning); + background: var(--el-color-warning-light-9); + border: 1px solid var(--el-color-warning-light-5); + border-radius: 4px; + + p { + margin: 0 0 4px; + } + + ul { + margin: 0; + padding-left: 18px; + + li { + margin: 2px 0; + word-break: break-all; + } + } + } + .switch-row { display: flex; gap: 10px; diff --git a/admin/src/views/site-import/model.ts b/admin/src/views/site-import/model.ts index db1f6c5..c17749e 100644 --- a/admin/src/views/site-import/model.ts +++ b/admin/src/views/site-import/model.ts @@ -46,7 +46,8 @@ export interface SiteImportDraft { evaluator: string } /** 墙尾巴三语(deflections_contact 池的已留资态收尾句);es 可 null - * (合法降级——wall_schema 只必留 en/zh,保存时不触 es 池) */ + * (合法降级——保存时整键丢弃 es 池,spec §3.2:防出厂 es 池行业身份 + * 泄漏;西语会话由 boundary_filter 回退看到新 EN 尾巴) */ wall_tails: { en: string; zh: string; es: string | null } contact: { cc_completion: boolean; email_light_ask: boolean } /** 白话注记清单(规格判定/剔除/移除/缺省兜底)——分级见 warningsLevel */ @@ -183,7 +184,7 @@ export interface SaveStep { * ——上传头像会写新版本(brand.avatar 落文件名),之后再读-改-写才不把 * 文件名丢掉(spec §5)。wall 步的池形态分派:deflections_contact 出厂 * 单句 → 整句替换;已多句 → 追加(以保存时 GET 快照为准,es 尾巴 null - * 则不触 es 池)。 + * 则整键丢弃 es 池——spec §3.2)。 */ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] { const steps: SaveStep[] = [] @@ -208,7 +209,7 @@ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] { { kind: 'wall', label: '安全词表(墙尾巴)', - note: 'GET config/wall 读-改-写:deflections_contact 按池形态分派——单句→替换、多句→追加(es 尾巴 null 不触 es 池),其余键透传' + note: 'GET config/wall 读-改-写:deflections_contact 按池形态分派——单句→替换、多句→追加(es 尾巴 null = 整键丢弃 es 池,spec §3.2),其余键透传' }, { kind: 'contact', @@ -222,8 +223,11 @@ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] { // ---- 墙池形态分派(Task 9——保存时以新鲜 GET 快照判断替换/追加) ------------------------ /** 分派结果:mode=append 时 UI 提示「原池含人工编辑句,已追加而非替换」; - * pools 按语言给出目标池,null = 不触该语言(es 尾巴缺失的合法降级, - * 载荷保持 GET 快照原值) */ + * pools 按语言给出目标池,null = 该语言**整键丢弃**(es 尾巴缺失的合法 + * 降级——spec §3.2:防出厂 es 池的行业身份泄漏,如建材档案「tuberías」 + * 泄漏给非管道租户的西语客户;丢弃后西语会话由 boundary_filter 的 + * `pools.get(lang) or pools["en"]` 回退看到新 EN 尾巴,wall_schema 仅 + * 必留 en/zh,缺 es 编译/运行双安全) */ export interface WallDispatch { mode: 'replace' | 'append' pools: Record<'en' | 'zh' | 'es', string[] | null> @@ -241,7 +245,7 @@ function asStringList(value: unknown): string[] { * 保存时以**新鲜 GET** 的现池为判断基准,逐语言分派—— * - 池内 >1 句 = 含人工编辑句 → 追加到末项(原句保留); * - ≤1 句(出厂单句/空/缺键)→ 整句替换为新尾巴; - * - es 新尾巴 null → pools.es 为 null(不触该语言池)。 + * - es 新尾巴 null → pools.es 为 null(**整键丢弃**,见 WallDispatch 注)。 * 任一语言走了追加 → mode='append'(调用方展示追加提示文案)。 */ export function wallPoolDispatch( @@ -268,6 +272,24 @@ export function wallPoolDispatch( return { mode: appended ? 'append' : 'replace', pools } } +/** + * 分派结果 → deflections_contact 目标池(wall 保存步载荷):非 null 语言 + * 覆写、null 语言**整键删除**(实践上仅 es——en/zh 尾巴必填非空)。其 + * 余语言键原样透传(快照 spread)。 + */ +export function applyWallDispatch( + currentPools: Record, + dispatch: WallDispatch +): Record { + const nextPool: Record = { ...(currentPools ?? {}) } + for (const lang of ['en', 'zh', 'es'] as const) { + const pool = dispatch.pools[lang] + if (pool === null) delete nextPool[lang] + else nextPool[lang] = pool + } + return nextPool +} + // ---- warnings 分级 ------------------------------------------------------------------- /**