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 }} 句,西语客户将看到上方英文收尾句):
+
+
+
+
- 保存时按当前池形态分派:出厂单句池整句替换;已含人工编辑句(多句)的池追加到末项并提示。
+ 保存时按当前池形态分派:出厂单句池整句替换;已含人工编辑句(多句)的池追加到末项并提示。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 分级 -------------------------------------------------------------------
/**