diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts index 50affbd..aa365e4 100644 --- a/admin/src/views/config/center/__tests__/model.test.ts +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -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: '客户已经留过之后' }) + }) }) diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts index 51ed961..7b4d9cc 100644 --- a/admin/src/views/config/center/model.ts +++ b/admin/src/views/config/center/model.ts @@ -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 - const a = after as Record - 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 + 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 | null | undefined, after: Record | 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)))] } // ---- 试金石 ------------------------------------------------------------------------ diff --git a/docs/superpowers/plans/2026-10-14-config-center-ux-visual.md b/docs/superpowers/plans/2026-10-14-config-center-ux-visual.md index afa8673..ab05443 100644 --- a/docs/superpowers/plans/2026-10-14-config-center-ux-visual.md +++ b/docs/superpowers/plans/2026-10-14-config-center-ux-visual.md @@ -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 - const a = after as Record - 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 + 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 | null | undefined, after: Record | 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)))] } ```