diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts index aa365e4..66f13ad 100644 --- a/admin/src/views/config/center/__tests__/model.test.ts +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -36,12 +36,18 @@ import { buildWallPayload, COMPARE_SCOPE_ALL, collectRemovedWallVariables, + contactToForm, + contactToPayload, descendingVersions, + engineToForm, + engineToPayload, formatKB, formatVersionTime, initWallEdit, JSON_GROUP_FIELDS, jsonGroupDraft, + miscToForm, + miscToPayload, normalizeAiCredsGroup, normalizeEmailCredsGroup, normalizePromptsGroup, @@ -63,6 +69,8 @@ import { touchstoneSentences, utf8Bytes, validateEmailCredsForm, + validateEngineForm, + validateKeyValueRows, validatePushCredsForm, type EmailCredsForm, type PromptsDraft, @@ -830,3 +838,62 @@ describe('WALL_SCENARIOS(场景卡定义)', () => { expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' }) }) }) + +// ---- contact / engine / misc 表单化(Task 12;JsonGroup 退役配套) --------------------- + +describe('contact 表单化', () => { + it('快照 → 表单(bool 缺省 false / 语言数组)', () => { + expect(contactToForm({ cc_completion: true, email_light_ask: false, template_languages: ['en', 'zh'] })) + .toEqual({ ccCompletion: true, emailLightAsk: false, templateLanguages: ['en', 'zh'] }) + expect(contactToForm(null)).toEqual({ ccCompletion: false, emailLightAsk: false, templateLanguages: [] }) + }) + it('表单 → payload:接管键覆写 + 未知键透传', () => { + const snapshot = { cc_completion: false, email_light_ask: false, template_languages: [], _meta: 'keep' } + expect(contactToPayload({ ccCompletion: true, emailLightAsk: true, templateLanguages: ['en', ' '] }, snapshot)) + .toEqual({ _meta: 'keep', cc_completion: true, email_light_ask: true, template_languages: ['en'] }) + }) +}) + +describe('engine 表单化', () => { + it('快照 → 表单(数值转串 / seed 映射转行)', () => { + expect(engineToForm({ rate_limit_per_hour: 120, ws_conn_limit: 5, context_seed_keys: { a: 'b' } })) + .toEqual({ rateLimit: '120', wsConnLimit: '5', seedRows: [{ key: 'a', value: 'b' }] }) + expect(engineToForm(null)).toEqual({ rateLimit: '', wsConnLimit: '', seedRows: [] }) + }) + it('payload:空数值槽保快照原值(快照也无 → 键省略);未知键透传;seed 恒重建', () => { + const snapshot = { rate_limit_per_hour: 120, ws_conn_limit: 5, other: 1 } + expect(engineToPayload({ rateLimit: '', wsConnLimit: '9', seedRows: [] }, snapshot)) + .toEqual({ other: 1, rate_limit_per_hour: 120, ws_conn_limit: 9, context_seed_keys: {} }) + expect(engineToPayload({ rateLimit: '60', wsConnLimit: '', seedRows: [] }, null)) + .toEqual({ rate_limit_per_hour: 60, context_seed_keys: {} }) + }) + it('payload:seed 行空键值剔除、重建映射', () => { + expect(engineToPayload( + { rateLimit: '1', wsConnLimit: '1', seedRows: [{ key: 'product_name', value: 'app_product_name' }, { key: '', value: 'x' }] }, + {} + ).context_seed_keys).toEqual({ product_name: 'app_product_name' }) + }) + it('校验:非正整数报错', () => { + expect(validateEngineForm({ rateLimit: '0', wsConnLimit: '', seedRows: [] })) + .toEqual(['每 IP 每小时发言上限:须为正整数']) + expect(validateEngineForm({ rateLimit: '30', wsConnLimit: '2', seedRows: [] })).toEqual([]) + }) +}) + +describe('misc 表单化', () => { + it('往返:快照 → 表单 → payload 保未知键', () => { + const snapshot = { name_to_region_extras: { 'saudi arabia': '966' }, keep: true } + const form = miscToForm(snapshot) + expect(form.extrasRows).toEqual([{ key: 'saudi arabia', value: '966' }]) + expect(miscToPayload({ extrasRows: [{ key: 'saudi arabia', value: '966' }, { key: 'uae', value: '971' }] }, snapshot)) + .toEqual({ keep: true, name_to_region_extras: { 'saudi arabia': '966', uae: '971' } }) + }) +}) + +describe('KeyValue 行校验(engine seed / misc extras 共用)', () => { + it('重复键与空键报错', () => { + expect(validateKeyValueRows([{ key: 'a', value: '1' }, { key: 'a', value: '2' }], '映射')) + .toEqual(['映射:键 a 重复']) + expect(validateKeyValueRows([{ key: '', value: '1' }], '映射')).toEqual([]) + }) +}) diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts index 7b4d9cc..7a4f679 100644 --- a/admin/src/views/config/center/model.ts +++ b/admin/src/views/config/center/model.ts @@ -934,3 +934,148 @@ export const JSON_GROUP_FIELDS: Record< } ] } + +// ---- contact / engine / misc 表单化(spec §5.4-5.7;JsonGroup 退役配套) ---------------- + +export interface KeyValueRow { + key: string + value: string +} + +/** 键值行校验(空行整行忽略;非空键重复报错;半行序列化时剔除不报错) */ +export function validateKeyValueRows(rows: KeyValueRow[], label: string): string[] { + const seen = new Set() + const errors: string[] = [] + for (const row of rows) { + if (row.key.trim() === '' && row.value.trim() === '') continue + const key = row.key.trim() + if (key === '') continue // 半行(有 value 无 key)序列化时整行剔除,不报错 + if (seen.has(key)) errors.push(`${label}:键 ${key} 重复`) + seen.add(key) + } + return errors +} + +function rowsToRecord(rows: KeyValueRow[]): Record { + const out: Record = {} + for (const row of rows) { + const key = row.key.trim() + if (key !== '') out[key] = row.value + } + return out +} + +function recordToRows(value: unknown): KeyValueRow[] { + const src = asRecord(value) ?? {} + return Object.entries(src) + .filter(([, v]) => typeof v === 'string') + .map(([key, v]) => ({ key, value: v as string })) +} + +// ---- contact ------------------------------------------------------------------------ + +export interface ContactForm { + ccCompletion: boolean + emailLightAsk: boolean + templateLanguages: string[] +} + +export function contactToForm( + group: Record | null | undefined +): ContactForm { + const src = group ?? {} + return { + ccCompletion: src.cc_completion === true, + emailLightAsk: src.email_light_ask === true, + templateLanguages: strList(src.template_languages) + } +} + +export function contactToPayload( + form: ContactForm, + group: Record | null | undefined +): Record { + return { + ...(group ?? {}), + cc_completion: form.ccCompletion, + email_light_ask: form.emailLightAsk, + template_languages: form.templateLanguages.map((s) => s.trim()).filter(Boolean) + } +} + +// ---- engine ------------------------------------------------------------------------- + +export interface EngineForm { + /** 正整数串;'' = 未填(payload 保快照原值/省略键——绝不提交非法值) */ + rateLimit: string + wsConnLimit: string + seedRows: KeyValueRow[] +} + +function intToForm(value: unknown): string { + return typeof value === 'number' && Number.isInteger(value) ? String(value) : '' +} + +export function engineToForm( + group: Record | null | undefined +): EngineForm { + const src = group ?? {} + return { + rateLimit: intToForm(src.rate_limit_per_hour), + wsConnLimit: intToForm(src.ws_conn_limit), + seedRows: recordToRows(src.context_seed_keys) + } +} + +/** + * 表单 → payload:空数值槽 = 保快照原值(spread 已带上;快照无值则不带键 + * ——resolver 出厂回退),非空槽覆写;context_seed_keys 行编辑恒重建(空行 + * 剔除——用户显式管理该映射,清空即提交空映射);其余未知键透传。 + */ +export function engineToPayload( + form: EngineForm, + group: Record | null | undefined +): Record { + const out: Record = { ...(group ?? {}) } + const rate = form.rateLimit.trim() + if (rate !== '') out.rate_limit_per_hour = Number(rate) + const ws = form.wsConnLimit.trim() + if (ws !== '') out.ws_conn_limit = Number(ws) + out.context_seed_keys = rowsToRecord(form.seedRows) + return out +} + +export function validateEngineForm(form: EngineForm): string[] { + const errors: string[] = [] + const rate = form.rateLimit.trim() + if (rate !== '' && (!/^\d+$/.test(rate) || Number(rate) < 1)) { + errors.push('每 IP 每小时发言上限:须为正整数') + } + const ws = form.wsConnLimit.trim() + if (ws !== '' && (!/^\d+$/.test(ws) || Number(ws) < 1)) { + errors.push('每 IP WebSocket 连接上限:须为正整数') + } + return errors +} + +// ---- misc --------------------------------------------------------------------------- + +export interface MiscForm { + extrasRows: KeyValueRow[] +} + +export function miscToForm( + group: Record | null | undefined +): MiscForm { + return { extrasRows: recordToRows((group ?? {}).name_to_region_extras) } +} + +export function miscToPayload( + form: MiscForm, + group: Record | null | undefined +): Record { + return { + ...(group ?? {}), + name_to_region_extras: rowsToRecord(form.extrasRows) + } +}