feat(admin): wall 重组辅助纯函数(场景卡/语言并集/模板变量保护)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
|||||||
REMINDER_VARIANTS,
|
REMINDER_VARIANTS,
|
||||||
RESET_GROUP_LABELS,
|
RESET_GROUP_LABELS,
|
||||||
TRIGGER_KEYS,
|
TRIGGER_KEYS,
|
||||||
|
WALL_SCENARIOS,
|
||||||
ZERO_NUMBER_KEYS,
|
ZERO_NUMBER_KEYS,
|
||||||
asSecretMasked,
|
asSecretMasked,
|
||||||
buildAiCredsPayload,
|
buildAiCredsPayload,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
buildPushCredsPayload,
|
buildPushCredsPayload,
|
||||||
buildWallPayload,
|
buildWallPayload,
|
||||||
COMPARE_SCOPE_ALL,
|
COMPARE_SCOPE_ALL,
|
||||||
|
collectRemovedWallVariables,
|
||||||
descendingVersions,
|
descendingVersions,
|
||||||
formatKB,
|
formatKB,
|
||||||
formatVersionTime,
|
formatVersionTime,
|
||||||
@@ -46,14 +48,17 @@ import {
|
|||||||
normalizePushCredsGroup,
|
normalizePushCredsGroup,
|
||||||
parsePatternEntry,
|
parsePatternEntry,
|
||||||
parseJsonGroupDraft,
|
parseJsonGroupDraft,
|
||||||
|
poolLanguages,
|
||||||
promptByteInfo,
|
promptByteInfo,
|
||||||
promptsOverCap,
|
promptsOverCap,
|
||||||
|
removedTemplateVariables,
|
||||||
rowToEntry,
|
rowToEntry,
|
||||||
rowsToEntries,
|
rowsToEntries,
|
||||||
rowToSingleEntry,
|
rowToSingleEntry,
|
||||||
scopedConfigJson,
|
scopedConfigJson,
|
||||||
secretFieldState,
|
secretFieldState,
|
||||||
snapshotGroupKeys,
|
snapshotGroupKeys,
|
||||||
|
templateVariables,
|
||||||
touchstoneSentences,
|
touchstoneSentences,
|
||||||
utf8Bytes,
|
utf8Bytes,
|
||||||
validateEmailCredsForm,
|
validateEmailCredsForm,
|
||||||
@@ -767,3 +772,51 @@ describe('secretFieldState(密钥四件套展示态)', () => {
|
|||||||
expect(s).toMatchObject({ badge: 'pending-clear', badgeType: 'warning', badgeLabel: '将清除 · 未保存' })
|
expect(s).toMatchObject({ badge: 'pending-clear', badgeType: 'warning', badgeLabel: '将清除 · 未保存' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护) -------------------------------
|
||||||
|
|
||||||
|
describe('poolLanguages(场景卡语言 tabs)', () => {
|
||||||
|
it('多池语言并集,必留语言在前、附加语言按序', () => {
|
||||||
|
expect(poolLanguages({
|
||||||
|
deflections: { en: ['a'], zh: ['b'], es: ['c'] },
|
||||||
|
deflections_contact: { en: ['a'], pt: ['d'] }
|
||||||
|
})).toEqual(['en', 'zh', 'es', 'pt'])
|
||||||
|
})
|
||||||
|
it('空池 → 空数组', () => {
|
||||||
|
expect(poolLanguages({ deflections: {}, deflections_contact: {} })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('模板变量保护', () => {
|
||||||
|
it('提取 {var} 变量', () => {
|
||||||
|
expect(templateVariables('到 {intl} 联系您,{shown} 已留')).toEqual(['intl', 'shown'])
|
||||||
|
})
|
||||||
|
it('删除快照出现过的变量被检出', () => {
|
||||||
|
expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl'])
|
||||||
|
expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([])
|
||||||
|
})
|
||||||
|
it('wall 整组递归检出(池+模板+提醒),同变量去重', () => {
|
||||||
|
const before = {
|
||||||
|
deflections: { en: ['call {intl}'] },
|
||||||
|
cc_templates: { echo: { en: 'at {intl}' } },
|
||||||
|
reminders: { cc_guide: { echo: '补全为 {intl}' } }
|
||||||
|
}
|
||||||
|
const after = {
|
||||||
|
deflections: { en: ['call me'] },
|
||||||
|
cc_templates: { echo: { en: 'at {intl}' } },
|
||||||
|
reminders: { cc_guide: { echo: '补全为 {intl}' } }
|
||||||
|
}
|
||||||
|
expect(collectRemovedWallVariables(before, after)).toEqual(['intl'])
|
||||||
|
})
|
||||||
|
it('快照缺键不误报', () => {
|
||||||
|
expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WALL_SCENARIOS(场景卡定义)', () => {
|
||||||
|
it('两池场景卡:同场景 × 未留资/已留资(boundary_filter has_contact 语义)', () => {
|
||||||
|
const price = WALL_SCENARIOS.find((s) => s.key === 'price')
|
||||||
|
expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact'])
|
||||||
|
expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -418,6 +418,104 @@ export function buildWallPayload(model: WallEditModel): Record<string, unknown>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护——spec §5.2) ------------------
|
||||||
|
|
||||||
|
/** 池语言并集(场景卡 tabs 数据源;必留语言在前、附加语言按字母序——排序稳定) */
|
||||||
|
export function poolLanguages(
|
||||||
|
pools: Record<string, Record<string, string[]>>
|
||||||
|
): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const pool of Object.values(pools)) {
|
||||||
|
for (const lang of Object.keys(pool)) set.add(lang)
|
||||||
|
}
|
||||||
|
const required = POOL_REQUIRED_LANGS.filter((l) => set.has(l))
|
||||||
|
const extra = [...set]
|
||||||
|
.filter((l) => !(POOL_REQUIRED_LANGS as readonly string[]).includes(l))
|
||||||
|
.sort()
|
||||||
|
return [...required, ...extra]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场景卡池位:field = wall 组键;when = 客户联系方式状态(boundary_filter has_contact) */
|
||||||
|
export interface ScenarioPool {
|
||||||
|
field: PoolField
|
||||||
|
when: 'not_given' | 'given'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioDef {
|
||||||
|
key: 'price' | 'pivot'
|
||||||
|
title: string
|
||||||
|
note: string
|
||||||
|
pools: readonly ScenarioPool[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 两张双状态场景卡(no_contact / cc_templates 结构非池,模板层直接渲染不走本表) */
|
||||||
|
export const WALL_SCENARIOS: readonly ScenarioDef[] = [
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
title: '问价格等敏感话题怎么回',
|
||||||
|
note: '客户问到价格/优惠/交付等不便直接回答的话题时,AI 用这些话术引导留联系方式',
|
||||||
|
pools: [
|
||||||
|
{ field: 'deflections', when: 'not_given' },
|
||||||
|
{ field: 'deflections_contact', when: 'given' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pivot',
|
||||||
|
title: '怎么把话题引到要联系方式',
|
||||||
|
note: 'AI 主动转向时用的话术——先回应客户话题,再顺势要 WhatsApp 或邮箱',
|
||||||
|
pools: [
|
||||||
|
{ field: 'pivots', when: 'not_given' },
|
||||||
|
{ field: 'pivots_contact', when: 'given' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 池位状态 → 卡内小节标题 */
|
||||||
|
export const SCENARIO_WHEN_LABELS: Record<ScenarioPool['when'], string> = {
|
||||||
|
not_given: '客户还没留联系方式时',
|
||||||
|
given: '客户已经留过之后'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模板变量保护({intl}/{shown}/{no_contact_web} 等运行期回填占位符) ---------------
|
||||||
|
|
||||||
|
/** 文本中的 {var} 占位符提取(字母/下划线开头,去重保序) */
|
||||||
|
export function templateVariables(text: string): string[] {
|
||||||
|
return [...new Set([...text.matchAll(/\{([a-z_][a-z0-9_]*)\}/gi)].map((m) => m[1]))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** before 出现过且 after 不再出现的变量(保存前警告清单) */
|
||||||
|
export function removedTemplateVariables(before: string, after: string): string[] {
|
||||||
|
const afterSet = new Set(templateVariables(after))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */
|
||||||
|
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 ?? {}
|
||||||
|
for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) {
|
||||||
|
collectRemovedStrings(b[key], a[key], out)
|
||||||
|
}
|
||||||
|
return [...new Set(out)]
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 试金石 ------------------------------------------------------------------------
|
// ---- 试金石 ------------------------------------------------------------------------
|
||||||
|
|
||||||
/** 试金石默认示例句:一句价格墙(blocked)/ 一句零号码墙(replaced)/
|
/** 试金石默认示例句:一句价格墙(blocked)/ 一句零号码墙(replaced)/
|
||||||
|
|||||||
Reference in New Issue
Block a user