fix(admin): 变量集差替代配对递归(删尾行/整键检出)+ 标签契约钉位

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 19:03:36 +08:00
parent 2e6811a55b
commit 36fe534029
3 changed files with 56 additions and 39 deletions
@@ -55,6 +55,7 @@ import {
rowToEntry,
rowsToEntries,
rowToSingleEntry,
SCENARIO_WHEN_LABELS,
scopedConfigJson,
secretFieldState,
snapshotGroupKeys,
@@ -795,7 +796,7 @@ describe('模板变量保护', () => {
expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl'])
expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([])
})
it('wall 整组递归检出(池+模板+提醒),同变量去重', () => {
it('wall 整组递归检出(池+模板+提醒),同变量去重(全域消失才报)', () => {
const before = {
deflections: { en: ['call {intl}'] },
cc_templates: { echo: { en: 'at {intl}' } },
@@ -803,14 +804,20 @@ describe('模板变量保护', () => {
}
const after = {
deflections: { en: ['call me'] },
cc_templates: { echo: { en: 'at {intl}' } },
reminders: { cc_guide: { echo: '补全为 {intl}' } }
cc_templates: { echo: { en: 'at email' } },
reminders: { cc_guide: { echo: '补全为手机号' } }
}
expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
})
it('快照缺键不误报', () => {
expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
})
it('删除含变量的尾行/整语言键同样检出(位置无关)', () => {
const before = { deflections: { en: ['plain', 'call {intl}'] } }
expect(collectRemovedWallVariables(before, { deflections: { en: ['plain'] } })).toEqual(['intl'])
const before2 = { cc_templates: { echo: { en: 'at {intl}' } } }
expect(collectRemovedWallVariables(before2, { cc_templates: { echo: {} } })).toEqual(['intl'])
})
})
describe('WALL_SCENARIOS(场景卡定义)', () => {
@@ -819,4 +826,7 @@ describe('WALL_SCENARIOS(场景卡定义)', () => {
expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact'])
expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given'])
})
it('状态标签契约(ScenarioCard 消费)', () => {
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
})
})
+15 -16
View File
@@ -489,31 +489,30 @@ export function removedTemplateVariables(before: string, after: string): string[
return templateVariables(before).filter((v) => !afterSet.has(v))
}
function collectRemovedStrings(before: unknown, after: unknown, out: string[]): void {
if (typeof before === 'string' && typeof after === 'string') {
out.push(...removedTemplateVariables(before, after))
} else if (
before !== null && after !== null &&
typeof before === 'object' && typeof after === 'object'
) {
const b = before as Record<string, unknown>
const a = after as Record<string, unknown>
for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out)
function collectVariables(value: unknown, out: string[]): void {
if (typeof value === 'string') {
out.push(...templateVariables(value))
} else if (value !== null && typeof value === 'object') {
// typeof 收窄只到 object(无索引签名)——与上方 asRecord 同款断言收形
const record = value as Record<string, unknown>
for (const key of Object.keys(record)) collectVariables(record[key], out)
}
}
/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */
/** wall 整组文本区(池/no_contact/cc_templates/reminders)变量集差——
* before 出现过且 after 全域不再出现的变量(位置无关:删除尾行/整键同样检出) */
export function collectRemovedWallVariables(
before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined
): string[] {
const out: string[] = []
const b = before ?? {}
const a = after ?? {}
const beforeVars: string[] = []
const afterVars: string[] = []
for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
collectRemovedStrings(b[key], a[key], out)
collectVariables((before ?? {})[key], beforeVars)
collectVariables((after ?? {})[key], afterVars)
}
return [...new Set(out)]
const afterSet = new Set(afterVars)
return [...new Set(beforeVars.filter((v) => !afterSet.has(v)))]
}
// ---- 试金石 ------------------------------------------------------------------------
@@ -507,7 +507,7 @@ emits 声明追加 `(e: 'navigate', group: string): void`。
- [ ] **Step 1: 写失败测试**:
```ts
import { poolLanguages, templateVariables, removedTemplateVariables, collectRemovedWallVariables, WALL_SCENARIOS } from '../model'
import { poolLanguages, templateVariables, removedTemplateVariables, collectRemovedWallVariables, WALL_SCENARIOS, SCENARIO_WHEN_LABELS } from '../model'
describe('poolLanguages(场景卡语言 tabs)', () => {
it('多池语言并集,必留语言在前、附加语言按序', () => {
@@ -529,7 +529,7 @@ describe('模板变量保护', () => {
expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl'])
expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([])
})
it('wall 整组递归检出(池+模板+提醒),同变量去重', () => {
it('wall 整组递归检出(池+模板+提醒),同变量去重(全域消失才报)', () => {
const before = {
deflections: { en: ['call {intl}'] },
cc_templates: { echo: { en: 'at {intl}' } },
@@ -537,14 +537,20 @@ describe('模板变量保护', () => {
}
const after = {
deflections: { en: ['call me'] },
cc_templates: { echo: { en: 'at {intl}' } },
reminders: { cc_guide: { echo: '补全为 {intl}' } }
cc_templates: { echo: { en: 'at email' } },
reminders: { cc_guide: { echo: '补全为手机号' } }
}
expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
})
it('快照缺键不误报', () => {
expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
})
it('删除含变量的尾行/整语言键同样检出(位置无关)', () => {
const before = { deflections: { en: ['plain', 'call {intl}'] } }
expect(collectRemovedWallVariables(before, { deflections: { en: ['plain'] } })).toEqual(['intl'])
const before2 = { cc_templates: { echo: { en: 'at {intl}' } } }
expect(collectRemovedWallVariables(before2, { cc_templates: { echo: {} } })).toEqual(['intl'])
})
})
describe('WALL_SCENARIOS(场景卡定义)', () => {
@@ -553,6 +559,9 @@ describe('WALL_SCENARIOS(场景卡定义)', () => {
expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact'])
expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given'])
})
it('状态标签契约(ScenarioCard 消费)', () => {
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
})
})
```
@@ -631,31 +640,30 @@ export function removedTemplateVariables(before: string, after: string): string[
return templateVariables(before).filter((v) => !afterSet.has(v))
}
function collectRemovedStrings(before: unknown, after: unknown, out: string[]): void {
if (typeof before === 'string' && typeof after === 'string') {
out.push(...removedTemplateVariables(before, after))
} else if (
before !== null && after !== null &&
typeof before === 'object' && typeof after === 'object'
) {
const b = before as Record<string, unknown>
const a = after as Record<string, unknown>
for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out)
function collectVariables(value: unknown, out: string[]): void {
if (typeof value === 'string') {
out.push(...templateVariables(value))
} else if (value !== null && typeof value === 'object') {
// typeof 收窄只到 object(无索引签名)——断言收形
const record = value as Record<string, unknown>
for (const key of Object.keys(record)) collectVariables(record[key], out)
}
}
/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */
/** wall 整组文本区(池/no_contact/cc_templates/reminders)变量集差——
* before 出现过且 after 全域不再出现的变量(位置无关:删除尾行/整键同样检出) */
export function collectRemovedWallVariables(
before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined
): string[] {
const out: string[] = []
const b = before ?? {}
const a = after ?? {}
const beforeVars: string[] = []
const afterVars: string[] = []
for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
collectRemovedStrings(b[key], a[key], out)
collectVariables((before ?? {})[key], beforeVars)
collectVariables((after ?? {})[key], afterVars)
}
return [...new Set(out)]
const afterSet = new Set(afterVars)
return [...new Set(beforeVars.filter((v) => !afterSet.has(v)))]
}
```