Archived
契约:launchOption 加 group/groupLabel(x-options.group 从字典表现读人读名)、 launchParam 加 linkGroup(x-link 结组);api-report 已随 --update 入库。 前端 launch-link.ts 纯函数管三条口径:锚摆全量、其余按锚的分组键收窄 (未标类型殿后一组,新增照走);锚粘着 —— 清空只解除收窄不换锚,人改得了主意。 服务端不据此立墙:combo 本就收候选之外的值,类型一致归 S1 收步判据 sub_eval_part_distinct_chip_type_id = 1(数得出来的门)。 W05 清单两格挂上 x-link: chip-type;文档同步。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -287,6 +287,28 @@ describe('契约', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 按芯片类型联动(2026-08-31 用户定):候选带上分组键与人读名,两格结成一组。
|
||||||
|
* 分组键指的那一列必须是候选表**真有**的列(`parts.profile.chip_type_id`),
|
||||||
|
* 字典指的那一张表也得是本活动声明过的 —— 写错地址不会炸,只会静默不联动(服务端 warn 一句),
|
||||||
|
* 而「静默不联动」正是没人会发现的那种坏。
|
||||||
|
*/
|
||||||
|
it('两格按芯片类型联动:候选带分组键 + 字典换人读名,x-link 同名结成一组', () => {
|
||||||
|
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
|
||||||
|
const ledgerOf = (address: string) => manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
|
||||||
|
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
|
||||||
|
expect(props[key]?.['x-link']).toBe('chip-type');
|
||||||
|
const options = props[key]?.['x-options'] as { group?: { column?: string; ledger?: string; value?: string; label?: string } };
|
||||||
|
expect(options.group).toEqual({ column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' });
|
||||||
|
// 分组键那一列在候选表上真的存在
|
||||||
|
expect(ledgerOf('parts.profile')?.fields.some((f) => f.name === options.group?.column)).toBe(true);
|
||||||
|
// 换名字典的两列也真的存在
|
||||||
|
const dict = ledgerOf('parts.chip_type');
|
||||||
|
expect(dict?.fields.some((f) => f.name === options.group?.value)).toBe(true);
|
||||||
|
expect(dict?.fields.some((f) => f.name === options.group?.label)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
|
it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
|
||||||
expect(manifest.contract.resources.tools).toEqual([]);
|
expect(manifest.contract.resources.tools).toEqual([]);
|
||||||
expect(manifest.declarations.tools).toEqual([]);
|
expect(manifest.declarations.tools).toEqual([]);
|
||||||
|
|||||||
@@ -89,7 +89,14 @@ export const manifest: ActivityManifest = {
|
|||||||
*
|
*
|
||||||
* 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从
|
* 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从
|
||||||
* `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在
|
* `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在
|
||||||
* jsonb 里,书写顺序到不了运营端)。requestDescription / candidateInternalPns(数组)/
|
* jsonb 里,书写顺序到不了运营端)。
|
||||||
|
*
|
||||||
|
* **两格按芯片类型联动**(2026-08-31 用户定):`x-options.group` 让候选带上各自的
|
||||||
|
* `chip_type_id` 与它在 `parts.chip_type` 里的人读名,`x-link: 'chip-type'` 把两格结成一组 ——
|
||||||
|
* 组内**先填的那格是锚**(摆全量),另一格只列同类型的料 + 库里未标类型的(殿后一组)+「新增」。
|
||||||
|
* 锚没有类型、或锚填的是库里没有的新料号 → 不收窄(两颗都还没建档时不该拦着人)。
|
||||||
|
* **这只是表单的引导,不是墙**:服务端照收两格不同类型的组合(`launch-params.ts` 头注),
|
||||||
|
* 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。requestDescription / candidateInternalPns(数组)/
|
||||||
* datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填
|
* datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填
|
||||||
* datasheet」这几件事。
|
* datasheet」这几件事。
|
||||||
*/
|
*/
|
||||||
@@ -103,8 +110,9 @@ export const manifest: ActivityManifest = {
|
|||||||
description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
|
description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
|
||||||
examples: ['LM2596S-5.0'],
|
examples: ['LM2596S-5.0'],
|
||||||
'x-control': 'combo',
|
'x-control': 'combo',
|
||||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' },
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||||
'x-order': 1,
|
'x-order': 1,
|
||||||
|
'x-link': 'chip-type',
|
||||||
},
|
},
|
||||||
candidateInternalPn: {
|
candidateInternalPn: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -112,8 +120,9 @@ export const manifest: ActivityManifest = {
|
|||||||
description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
|
description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
|
||||||
examples: ['XL2596S-5.0'],
|
examples: ['XL2596S-5.0'],
|
||||||
'x-control': 'combo',
|
'x-control': 'combo',
|
||||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' },
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||||
'x-order': 2,
|
'x-order': 2,
|
||||||
|
'x-link': 'chip-type',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -296,9 +296,16 @@ export declare const launchParamTypeSchema: z.ZodEnum<{
|
|||||||
list: "list";
|
list: "list";
|
||||||
}>;
|
}>;
|
||||||
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
||||||
|
/**
|
||||||
|
* 一条候选。`group` 是**联动分组键**(2026-08-31 用户定):清单 `x-options.group` 指了哪一列,
|
||||||
|
* 这颗候选就带上它在那一列的值(如料号的 `chip_type_id`);空串 = 那一列没值(「未标类型」)。
|
||||||
|
* `groupLabel` 是这枚键的人读名(从字典表现读,如「DC-DC 降压稳压器」),读不到就没有 —— 界面退回摆键本身。
|
||||||
|
*/
|
||||||
export declare const launchOptionSchema: z.ZodObject<{
|
export declare const launchOptionSchema: z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
||||||
export declare const launchParamSchema: z.ZodObject<{
|
export declare const launchParamSchema: z.ZodObject<{
|
||||||
@@ -317,8 +324,11 @@ export declare const launchParamSchema: z.ZodObject<{
|
|||||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
linkGroup: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
||||||
export declare const launchParamsSchema: z.ZodObject<{
|
export declare const launchParamsSchema: z.ZodObject<{
|
||||||
@@ -340,8 +350,11 @@ export declare const launchParamsSchema: z.ZodObject<{
|
|||||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
linkGroup: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
||||||
|
|||||||
@@ -176,8 +176,11 @@ export declare const opsLaunchParamsRoute: {
|
|||||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
value: z.ZodString;
|
value: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
|
group: z.ZodOptional<z.ZodString>;
|
||||||
|
groupLabel: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||||
|
linkGroup: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -208,3 +208,88 @@ describe('validateLaunchParams', () => {
|
|||||||
expect(nan.ok).toBe(false);
|
expect(nan.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── 按分组键联动(2026-08-31 用户定:原料号 / 候选料号按芯片类型互相收窄)────────────
|
||||||
|
派生层只负责把两样东西送到前端:①候选带上它的分组键与人读名(`x-options.group`);
|
||||||
|
②这一格属于哪个联动组(`x-link`)。收窄怎么摆是前端的事,**服务端不据此立墙**。 */
|
||||||
|
describe('x-options.group / x-link', () => {
|
||||||
|
const LINKED = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||||
|
properties: {
|
||||||
|
originalInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '原内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': {
|
||||||
|
ledger: 'parts.profile',
|
||||||
|
value: 'internal_pn',
|
||||||
|
label: 'internal_pn',
|
||||||
|
group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' },
|
||||||
|
},
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
'x-order': 1,
|
||||||
|
},
|
||||||
|
candidateInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '候选内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: 'chip_type_id' },
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
'x-order': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('分组键两种写法都认:整份声明(带字典)与只写列名;x-link 读成联动组名', () => {
|
||||||
|
const specs = fieldSpecsOf(LINKED);
|
||||||
|
expect(specs[0]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: { ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } });
|
||||||
|
// 只写列名 = 有分组键、没有人读名字典
|
||||||
|
expect(specs[1]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: undefined });
|
||||||
|
expect(specs.map((s) => s.linkGroup)).toEqual(['chip-type', 'chip-type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分组键写残(没有 column,或字典缺 ledger/value)就当没声明 —— 候选照出,只是不联动', () => {
|
||||||
|
const bad = fieldSpecsOf({
|
||||||
|
properties: {
|
||||||
|
a: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: {} } },
|
||||||
|
b: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: { column: 'c', ledger: 'd.t' } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bad[0]?.optionsSource).toEqual({ ledger: 'l.o', value: 'v', label: 'v', group: undefined });
|
||||||
|
// 字典写残 → 只留分组键,人读名没有(不炸)
|
||||||
|
expect(bad[1]?.optionsSource?.group).toEqual({ column: 'c', dict: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('候选带 group / groupLabel 原样交给前端;有候选才给 linkGroup(一个候选都没有,联动无从谈起)', async () => {
|
||||||
|
const params = await launchParamsOf(LINKED, async (src) =>
|
||||||
|
src.group === undefined
|
||||||
|
? [{ value: 'AMS1117-3.3', label: 'AMS1117-3.3' }]
|
||||||
|
: [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(params[0]).toMatchObject({
|
||||||
|
key: 'originalInternalPn',
|
||||||
|
linkGroup: 'chip-type',
|
||||||
|
options: [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 候选读不到 → 仍是 combo,但不摆联动组(前端没有可收窄的东西)
|
||||||
|
const empty = await launchParamsOf(LINKED, noResolve);
|
||||||
|
expect(empty[0]?.linkGroup).toBeUndefined();
|
||||||
|
expect(empty[0]?.type).toBe('combo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('服务端不据分组立墙:两格填不同类型的料号照样收(类型一致归活动的收步判据管)', async () => {
|
||||||
|
const params = await launchParamsOf(LINKED, async () => [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1' },
|
||||||
|
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' },
|
||||||
|
]);
|
||||||
|
const out = validateLaunchParams(params, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, fieldSpecsOf(LINKED));
|
||||||
|
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -374,6 +374,80 @@ describe('启动', () => {
|
|||||||
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 联动(2026-08-31 用户定):候选行上多取一列当分组键,再从字典表换人读名交给前端 */
|
||||||
|
const LINKED_MANIFEST = (): ManifestFace =>
|
||||||
|
manifest({
|
||||||
|
contract: {
|
||||||
|
input: {
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||||
|
properties: {
|
||||||
|
originalInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '原内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||||
|
'x-order': 1,
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
},
|
||||||
|
candidateInternalPn: {
|
||||||
|
type: 'string',
|
||||||
|
title: '候选内部料号',
|
||||||
|
'x-control': 'combo',
|
||||||
|
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||||
|
'x-order': 2,
|
||||||
|
'x-link': 'chip-type',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const PARTS: Record<string, Record<string, unknown>[]> = {
|
||||||
|
'parts.profile': [
|
||||||
|
{ internal_pn: 'AMS1117-3.3', chip_type_id: '2' },
|
||||||
|
{ internal_pn: 'LM2596S-5.0', chip_type_id: '1' },
|
||||||
|
{ internal_pn: 'OLD-001', chip_type_id: null },
|
||||||
|
],
|
||||||
|
'parts.chip_type': [
|
||||||
|
{ type_id: '1', type_name: 'DC-DC 降压稳压器' },
|
||||||
|
{ type_id: '2', type_name: 'LDO 线性稳压器' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const partsLedger = (over: Partial<ActivityOpsDeps['ledger']> = {}): ActivityOpsDeps['ledger'] => ({
|
||||||
|
describe: async (address) => {
|
||||||
|
const rows = PARTS[`${address.domain}.${address.object}`];
|
||||||
|
return rows === undefined ? undefined : { columns: Object.keys(rows[0] ?? {}).map((name) => ({ name })) };
|
||||||
|
},
|
||||||
|
query: async (q) => ({ rows: PARTS[`${q.address.domain}.${q.address.object}`] ?? [] }),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('候选带分组键 + 字典换来的人读名;那一列没值就是空串(未标类型),联动组名随参数下发', async () => {
|
||||||
|
const { ops } = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger() });
|
||||||
|
const table = await ops.launchParams(W05);
|
||||||
|
expect(table?.params.map((p) => p.linkGroup)).toEqual(['chip-type', 'chip-type']);
|
||||||
|
expect(table?.params[0]?.options).toEqual([
|
||||||
|
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分组那一列不存在 / 字典读不到 → 候选照出,只是没有键或没有名(不炸)', async () => {
|
||||||
|
const noColumn = harness({
|
||||||
|
manifest: LINKED_MANIFEST(),
|
||||||
|
ledger: partsLedger({ describe: async (a) => (a.object === 'profile' ? { columns: [{ name: 'internal_pn' }] } : { columns: [{ name: 'type_id' }, { name: 'type_name' }] }) }),
|
||||||
|
});
|
||||||
|
const t1 = await noColumn.ops.launchParams(W05);
|
||||||
|
expect(t1?.params[0]?.options?.every((o) => o.group === undefined)).toBe(true);
|
||||||
|
// 分组键在、字典表没物化 → 有键没名
|
||||||
|
const noDict = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger({ describe: async (a) => (a.object === 'chip_type' ? undefined : { columns: [{ name: 'internal_pn' }, { name: 'chip_type_id' }] }) }) });
|
||||||
|
const t2 = await noDict.ops.launchParams(W05);
|
||||||
|
expect(t2?.params[0]?.options?.[0]).toEqual({ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
||||||
const { ops, started } = harness({ enabled: false });
|
const { ops, started } = harness({ enabled: false });
|
||||||
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
||||||
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
||||||
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
||||||
|
* · `x-options.group`:候选的**分组键**(2026-08-31 用户定)—— 只写列名(`'chip_type_id'`)= 有键没名;
|
||||||
|
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
|
||||||
|
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
|
||||||
|
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
|
||||||
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||||
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||||
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||||
@@ -17,7 +21,13 @@
|
|||||||
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||||
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
||||||
|
|
||||||
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string };
|
/**
|
||||||
|
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
|
||||||
|
* `dict` 是把这枚键换成人读名的字典表(如 `parts.chip_type.type_id → type_name`);
|
||||||
|
* 字典没声明 / 写残 → 只有键没有名(界面退回摆键本身,不炸)。
|
||||||
|
*/
|
||||||
|
export type GroupSource = { readonly column: string; readonly dict: { readonly ledger: string; readonly value: string; readonly label: string } | undefined };
|
||||||
|
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string; readonly group: GroupSource | undefined };
|
||||||
|
|
||||||
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
||||||
export type FieldSpec = {
|
export type FieldSpec = {
|
||||||
@@ -34,6 +44,8 @@ export type FieldSpec = {
|
|||||||
readonly example: unknown;
|
readonly example: unknown;
|
||||||
readonly defaultValue: unknown;
|
readonly defaultValue: unknown;
|
||||||
readonly optionsSource: OptionsSource | undefined;
|
readonly optionsSource: OptionsSource | undefined;
|
||||||
|
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
|
||||||
|
readonly linkGroup: string | undefined;
|
||||||
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||||
readonly order: number | undefined;
|
readonly order: number | undefined;
|
||||||
};
|
};
|
||||||
@@ -46,6 +58,24 @@ function asString(value: unknown): string | undefined {
|
|||||||
return typeof value === 'string' ? value : undefined;
|
return typeof value === 'string' ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `x-options.group`:只写列名(字符串)= 有键没名;整份声明 = 键 + 换名字典。写残当没声明 —— 候选照出,只是不联动 */
|
||||||
|
function groupSourceOf(value: unknown): GroupSource | undefined {
|
||||||
|
const asColumn = asString(value);
|
||||||
|
if (asColumn !== undefined) return asColumn === '' ? undefined : { column: asColumn, dict: undefined };
|
||||||
|
const raw = asRecord(value);
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
const column = asString(raw['column']);
|
||||||
|
if (column === undefined || column === '') return undefined;
|
||||||
|
const ledger = asString(raw['ledger']);
|
||||||
|
const valueCol = asString(raw['value']);
|
||||||
|
const labelCol = asString(raw['label']);
|
||||||
|
const dict =
|
||||||
|
ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === ''
|
||||||
|
? undefined
|
||||||
|
: { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
||||||
|
return { column, dict };
|
||||||
|
}
|
||||||
|
|
||||||
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
||||||
const raw = asRecord(value);
|
const raw = asRecord(value);
|
||||||
if (raw === undefined) return undefined;
|
if (raw === undefined) return undefined;
|
||||||
@@ -53,7 +83,7 @@ function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
|||||||
const valueCol = asString(raw['value']);
|
const valueCol = asString(raw['value']);
|
||||||
const labelCol = asString(raw['label']);
|
const labelCol = asString(raw['label']);
|
||||||
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
||||||
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol, group: groupSourceOf(raw['group']) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
||||||
@@ -81,6 +111,7 @@ export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): Fi
|
|||||||
example: Array.isArray(examples) ? examples[0] : undefined,
|
example: Array.isArray(examples) ? examples[0] : undefined,
|
||||||
defaultValue: spec['default'],
|
defaultValue: spec['default'],
|
||||||
optionsSource: optionsSourceOf(spec['x-options']),
|
optionsSource: optionsSourceOf(spec['x-options']),
|
||||||
|
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
|
||||||
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -159,6 +190,8 @@ export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[]
|
|||||||
...(placeholder === undefined ? {} : { placeholder }),
|
...(placeholder === undefined ? {} : { placeholder }),
|
||||||
...(withOptions ? { options } : {}),
|
...(withOptions ? { options } : {}),
|
||||||
...(fallback === undefined ? {} : { default: fallback }),
|
...(fallback === undefined ? {} : { default: fallback }),
|
||||||
|
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
|
||||||
|
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import type { StartTaskInput } from '@modules/task';
|
|||||||
import { MIME_OF } from '../file-compose.ts';
|
import { MIME_OF } from '../file-compose.ts';
|
||||||
import type { FileFormat } from '../file-compose.ts';
|
import type { FileFormat } from '../file-compose.ts';
|
||||||
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
||||||
import type { FieldSpec, LaunchIssue, OptionsSource } from './launch-params.ts';
|
import type { FieldSpec, GroupSource, LaunchIssue, OptionsSource } from './launch-params.ts';
|
||||||
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
||||||
import type { StateVerdict } from './instance-state.ts';
|
import type { StateVerdict } from './instance-state.ts';
|
||||||
import { followRes, stepsOf } from './instance-steps.ts';
|
import { followRes, stepsOf } from './instance-steps.ts';
|
||||||
@@ -229,6 +229,34 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
|||||||
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组键的人读名字典(`x-options.group.dict`)—— 键 → 名。
|
||||||
|
* 表没物化 / 列不在 / 查不动 → 空表(候选仍带键、只是没有名;界面退回摆键本身,不炸)。
|
||||||
|
*/
|
||||||
|
async function groupLabels(dict: NonNullable<GroupSource['dict']>): Promise<ReadonlyMap<string, string>> {
|
||||||
|
const [domain, object] = dict.ledger.split('.', 2);
|
||||||
|
if (domain === undefined || object === undefined || domain === '' || object === '') return new Map();
|
||||||
|
const address = { domain, object };
|
||||||
|
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||||
|
if (described === undefined || !described.columns.some((c) => c.name === dict.value) || !described.columns.some((c) => c.name === dict.label)) {
|
||||||
|
warn(`[activity-ops] 分组人读名读不到:${dict.ledger} 未物化或没有列 ${dict.value} / ${dict.label}`);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
const page = await deps.ledger.query({ address, where: {}, orderBy: [], limit: OPTIONS_CAP, offset: 0 }).catch((error: unknown) => {
|
||||||
|
warn(`[activity-ops] 分组人读名查询失败:${dict.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const row of page?.rows ?? []) {
|
||||||
|
const key = row[dict.value];
|
||||||
|
const name = row[dict.label];
|
||||||
|
if (key === undefined || key === null || String(key) === '') continue;
|
||||||
|
if (name === undefined || name === null || String(name) === '') continue;
|
||||||
|
out.set(String(key), String(name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
||||||
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
||||||
const [domain, object] = source.ledger.split('.', 2);
|
const [domain, object] = source.ledger.split('.', 2);
|
||||||
@@ -251,13 +279,22 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
if (page === undefined) return undefined;
|
if (page === undefined) return undefined;
|
||||||
|
/* 分组键(联动用):声明了、且候选表真有这一列才取 —— 列不在就当没声明(候选照出,只是不联动) */
|
||||||
|
const group = source.group !== undefined && columns.has(source.group.column) ? source.group : undefined;
|
||||||
|
if (source.group !== undefined && group === undefined) warn(`[activity-ops] 分组键读不到:${source.ledger} 没有列 ${source.group.column}`);
|
||||||
|
const names = group?.dict === undefined ? new Map<string, string>() : await groupLabels(group.dict);
|
||||||
return page.rows
|
return page.rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const value = row[source.value];
|
const value = row[source.value];
|
||||||
const label = row[source.label];
|
const label = row[source.label];
|
||||||
const v = value === undefined || value === null ? '' : String(value);
|
const v = value === undefined || value === null ? '' : String(value);
|
||||||
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
||||||
return { value: v, label: l };
|
if (group === undefined) return { value: v, label: l };
|
||||||
|
const raw = row[group.column];
|
||||||
|
/* 那一列没值 = 「未标类型」:空串是它自己的一档,不是「没有分组键这回事」 */
|
||||||
|
const key = raw === undefined || raw === null ? '' : String(raw);
|
||||||
|
const name = names.get(key);
|
||||||
|
return { value: v, label: l, group: key, ...(name === undefined ? {} : { groupLabel: name }) };
|
||||||
})
|
})
|
||||||
.filter((o) => o.value !== '');
|
.filter((o) => o.value !== '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 启动参数的按分组联动(2026-08-31 用户定:原内部料号 / 候选内部料号按芯片类型互相收窄)。
|
||||||
|
*
|
||||||
|
* 三条口径由用户当场定死:
|
||||||
|
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除
|
||||||
|
* (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。
|
||||||
|
* ②未标类型的老料**列在末尾单独一组**,不是不列。
|
||||||
|
* ③锚改了类型之后,另一格里失配的值**清空**(人自己新增的值不动 —— 它压根不在候选里)。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { LaunchParam } from '@contracts';
|
||||||
|
import { comboFace, nextAnchors, staleLinked } from '../internal/launch-link.ts';
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'XL2596S-5.0', label: 'XL2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||||
|
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
|
||||||
|
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||||
|
];
|
||||||
|
const combo = (key: string, label: string): LaunchParam => ({ key, label, type: 'combo', required: true, options: OPTIONS, linkGroup: 'chip-type' });
|
||||||
|
const PARAMS: LaunchParam[] = [combo('originalInternalPn', '原内部料号'), combo('candidateInternalPn', '候选内部料号')];
|
||||||
|
/** 不联动的一格:同一张表上还有别的 combo,不该被卷进来 */
|
||||||
|
const LOOSE: LaunchParam = { key: 'customerCode', label: '客户', type: 'combo', options: [{ value: 'CU-0142', label: 'Itelma' }] };
|
||||||
|
|
||||||
|
const face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
|
||||||
|
comboFace(PARAMS.find((p) => p.key === key)!, [...PARAMS, LOOSE], values, anchors);
|
||||||
|
|
||||||
|
describe('nextAnchors —— 谁是锚', () => {
|
||||||
|
it('组内第一个填上值的那格入座;另一格随后填上也不改朝换代', () => {
|
||||||
|
const first = nextAnchors(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: '' }, {});
|
||||||
|
expect(first).toEqual({ 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(nextAnchors(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, first)).toEqual(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('先填候选那格,锚就是它(反过来一样)', () => {
|
||||||
|
expect(nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, {})).toEqual({ 'chip-type': 'candidateInternalPn' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**锚粘着**:把锚清空,收窄整组解除 —— 不把锚顺手交给还填着值的另一格', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const after = nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, anchors);
|
||||||
|
expect(after).toEqual(anchors);
|
||||||
|
// 锚空着 = 没有可收窄的依据:另一格摆全量(四条,分两类 + 未标类型)
|
||||||
|
expect(face('candidateInternalPn', { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, after).narrow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('整组都空了才重新认锚 —— 这次谁先填谁当', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const cleared = nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: '' }, anchors);
|
||||||
|
expect(cleared).toEqual({});
|
||||||
|
expect(nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'AMS1117-3.3' }, cleared)).toEqual({ 'chip-type': 'candidateInternalPn' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comboFace —— 这一格摆哪些候选', () => {
|
||||||
|
it('没有锚:全量,按类型分组,**未标类型殿后**', () => {
|
||||||
|
const f = face('originalInternalPn', {});
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
|
||||||
|
['DC-DC 降压稳压器', ['LM2596S-5.0', 'XL2596S-5.0']],
|
||||||
|
['LDO 线性稳压器', ['AMS1117-3.3']],
|
||||||
|
['未标类型', ['OLD-001']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('锚自己永远摆全量 —— 人想换类型,回去改锚就行', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const f = face('originalInternalPn', { originalInternalPn: 'LM2596S-5.0' }, anchors);
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('另一格按锚的类型收窄:同类型在前、未标类型殿后;点名按谁的什么类型收的', () => {
|
||||||
|
const f = face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
|
||||||
|
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
|
||||||
|
['DC-DC 降压稳压器', ['LM2596S-5.0', 'XL2596S-5.0']],
|
||||||
|
['未标类型', ['OLD-001']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**锚本身没有类型**(未标类型的老料)→ 另一格摆全部(用户原话)', () => {
|
||||||
|
const f = face('candidateInternalPn', { originalInternalPn: 'OLD-001' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.flatMap((g) => g.options)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**锚填的是新增料号**(库里没有这个值)→ 收窄无从谈起,另一格摆全部', () => {
|
||||||
|
const f = face('candidateInternalPn', { originalInternalPn: 'BRAND-NEW-PN' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups.flatMap((g) => g.options)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('类型只有键没有名(字典读不到)→ 分组标题退回摆键本身,不编一个名字', () => {
|
||||||
|
const bare: LaunchParam = { ...PARAMS[1]!, options: [{ value: 'A', label: 'A', group: '7' }] };
|
||||||
|
const f = comboFace(bare, [PARAMS[0]!, bare], {}, {});
|
||||||
|
expect(f.groups.map((g) => g.label)).toEqual(['7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不联动的 combo(没有 linkGroup / 候选不带分组键)→ 一组无标题,谁也不管它', () => {
|
||||||
|
const f = comboFace(LOOSE, [...PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
|
||||||
|
expect(f.narrow).toBeUndefined();
|
||||||
|
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('staleLinked —— 锚换了类型,另一格里失配的值', () => {
|
||||||
|
it('在全量候选里、却不在收窄后的候选里 → 点名清空', () => {
|
||||||
|
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: 'XL2596S-5.0' }, { 'chip-type': 'originalInternalPn' })).toEqual(['candidateInternalPn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同类型的、未标类型的、人自己新增的、空的 —— 一个都不动', () => {
|
||||||
|
const anchors = { 'chip-type': 'originalInternalPn' };
|
||||||
|
const keep = (candidate: string) => staleLinked(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: candidate }, anchors);
|
||||||
|
expect(keep('XL2596S-5.0')).toEqual([]);
|
||||||
|
expect(keep('OLD-001')).toEqual([]);
|
||||||
|
// 新增的值压根不在候选里 —— 它是人自己写的新料号,不是失配的旧选择
|
||||||
|
expect(keep('BRAND-NEW-PN')).toEqual([]);
|
||||||
|
expect(keep('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('锚自己不算失配(它摆的就是全量)', () => {
|
||||||
|
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: '' }, { 'chip-type': 'originalInternalPn' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 启动参数的**按分组联动**(2026-08-31 用户定)—— 纯函数,不碰 React。
|
||||||
|
*
|
||||||
|
* 服务端下发的两样东西就是这里的全部依据:候选各自带 `group` / `groupLabel`(清单 `x-options.group`),
|
||||||
|
* 参数带 `linkGroup`(清单 `x-link`)。前端**不认识**「原内部料号」「芯片类型」这些名字 ——
|
||||||
|
* 换个活动声明另一组联动,这段代码一个字不用改。
|
||||||
|
*
|
||||||
|
* 三条口径(用户当场定):
|
||||||
|
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量候选,其余格按锚的分组键收窄;
|
||||||
|
* 锚**粘着**:把它清空只是解除收窄,**不把锚交给还填着值的另一格** ——
|
||||||
|
* 否则「清空锚、改个类型重填」当场被另一格锁住,人就改不了主意了(这一条是这次设计的要害)。
|
||||||
|
* 整组都空了才重新认锚。
|
||||||
|
* ②锚没有分组键(未标类型的老料)、或锚填的是库里没有的新值(新增料号)→ 不收窄。
|
||||||
|
* ③收窄之后,「未标类型」的候选**列在末尾单独一组**,不是不列。
|
||||||
|
*
|
||||||
|
* 服务端不据此立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管。
|
||||||
|
*/
|
||||||
|
import type { LaunchOption, LaunchParam } from '@contracts';
|
||||||
|
|
||||||
|
/** 组名 → 当下坐着锚位的那格字段名 */
|
||||||
|
export type LinkAnchors = Readonly<Record<string, string>>;
|
||||||
|
/** 一组候选(`label` 为空 = 这批候选没有分组键,摆一列不分组) */
|
||||||
|
export type OptionGroup = { readonly label: string | undefined; readonly options: readonly LaunchOption[] };
|
||||||
|
/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有) */
|
||||||
|
export type ComboFace = { readonly groups: readonly OptionGroup[]; readonly narrow: { readonly by: string; readonly label: string } | undefined };
|
||||||
|
|
||||||
|
const UNGROUPED = '未标类型';
|
||||||
|
|
||||||
|
const valueOf = (values: Readonly<Record<string, string | readonly string[]>>, key: string): string => {
|
||||||
|
const v = values[key];
|
||||||
|
return typeof v === 'string' ? v.trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 同一联动组的几格(按表单次序);只认真有候选的那些 —— 没有候选就没有可收窄的东西 */
|
||||||
|
function membersOf(params: readonly LaunchParam[], group: string): readonly LaunchParam[] {
|
||||||
|
return params.filter((p) => p.linkGroup === group && (p.options ?? []).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 锚这一格填的值落在哪个分组键上;`undefined` = 收窄无从谈起(没填 / 填的是库里没有的新值) */
|
||||||
|
function anchorGroup(anchor: LaunchParam, value: string): { readonly key: string; readonly label: string } | undefined {
|
||||||
|
if (value === '') return undefined;
|
||||||
|
const hit = (anchor.options ?? []).find((o) => o.value === value);
|
||||||
|
// 值不在候选里 = 人自己新增的料号,它没有类型可言
|
||||||
|
if (hit === undefined || hit.group === undefined || hit.group === '') return undefined;
|
||||||
|
return { key: hit.group, label: hit.groupLabel ?? hit.group };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 值变过之后这一组的锚是谁。锚粘着:占了位就不让,直到组内所有格都空 —— 见头注①。
|
||||||
|
* 只动传进来的那份,原样返回也是合法结果(调用方按引用比较可省一次渲染)。
|
||||||
|
*/
|
||||||
|
export function nextAnchors(params: readonly LaunchParam[], values: Readonly<Record<string, string | readonly string[]>>, anchors: LinkAnchors): LinkAnchors {
|
||||||
|
const groups = new Set(params.map((p) => p.linkGroup).filter((g): g is string => g !== undefined));
|
||||||
|
let out = anchors;
|
||||||
|
const set = (group: string, key: string | undefined) => {
|
||||||
|
if (out === anchors) out = { ...anchors };
|
||||||
|
const next = { ...out };
|
||||||
|
if (key === undefined) delete next[group];
|
||||||
|
else next[group] = key;
|
||||||
|
out = next;
|
||||||
|
};
|
||||||
|
for (const group of groups) {
|
||||||
|
const members = membersOf(params, group);
|
||||||
|
const filled = members.filter((p) => valueOf(values, p.key) !== '');
|
||||||
|
const seated = anchors[group];
|
||||||
|
if (filled.length === 0) {
|
||||||
|
// 整组都空了 —— 锚位空出来,下一个填上值的那格重新认
|
||||||
|
if (seated !== undefined) set(group, undefined);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 锚位还坐着人(哪怕它此刻是空的)就不动;锚被别的活动版本改没了才重新认
|
||||||
|
if (seated !== undefined && members.some((p) => p.key === seated)) continue;
|
||||||
|
set(group, filled[0]?.key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一批候选按分组键分组:有键的按首次出现的次序,**没有键的殿后**(用户定) */
|
||||||
|
function groupOptions(options: readonly LaunchOption[]): readonly OptionGroup[] {
|
||||||
|
if (!options.some((o) => o.group !== undefined)) return [{ label: undefined, options }];
|
||||||
|
const named = new Map<string, { label: string; options: LaunchOption[] }>();
|
||||||
|
const bare: LaunchOption[] = [];
|
||||||
|
for (const o of options) {
|
||||||
|
const key = o.group ?? '';
|
||||||
|
if (key === '') {
|
||||||
|
bare.push(o);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hit = named.get(key);
|
||||||
|
if (hit === undefined) named.set(key, { label: o.groupLabel ?? key, options: [o] });
|
||||||
|
else hit.options.push(o);
|
||||||
|
}
|
||||||
|
const out: OptionGroup[] = [...named.values()].map((g) => ({ label: g.label, options: g.options }));
|
||||||
|
if (bare.length > 0) out.push({ label: UNGROUPED, options: bare });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量 */
|
||||||
|
export function comboFace(
|
||||||
|
param: LaunchParam,
|
||||||
|
params: readonly LaunchParam[],
|
||||||
|
values: Readonly<Record<string, string | readonly string[]>>,
|
||||||
|
anchors: LinkAnchors,
|
||||||
|
): ComboFace {
|
||||||
|
const all = param.options ?? [];
|
||||||
|
const group = param.linkGroup;
|
||||||
|
const seated = group === undefined ? undefined : anchors[group];
|
||||||
|
if (group === undefined || seated === undefined || seated === param.key) return { groups: groupOptions(all), narrow: undefined };
|
||||||
|
const anchor = params.find((p) => p.key === seated);
|
||||||
|
const by = anchor === undefined ? undefined : anchorGroup(anchor, valueOf(values, seated));
|
||||||
|
if (anchor === undefined || by === undefined) return { groups: groupOptions(all), narrow: undefined };
|
||||||
|
// 收窄:同分组键的 + 未标类型的(殿后)—— 「新增」那条路由控件自己一直留着
|
||||||
|
return { groups: groupOptions(all.filter((o) => o.group === by.key || o.group === undefined || o.group === '')), narrow: { by: anchor.label, label: by.label } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锚换了类型之后,组内哪几格的值**失配**:那个值在全量候选里、却不在收窄后的候选里。
|
||||||
|
* 人自己新增的值(压根不在候选里)不动 —— 它不是一次失配的旧选择,是一颗新料号。
|
||||||
|
*/
|
||||||
|
export function staleLinked(params: readonly LaunchParam[], values: Readonly<Record<string, string | readonly string[]>>, anchors: LinkAnchors): readonly string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const param of params) {
|
||||||
|
const value = valueOf(values, param.key);
|
||||||
|
if (value === '' || param.linkGroup === undefined) continue;
|
||||||
|
const face = comboFace(param, params, values, anchors);
|
||||||
|
if (face.narrow === undefined) continue;
|
||||||
|
const known = (param.options ?? []).some((o) => o.value === value);
|
||||||
|
const kept = face.groups.some((g) => g.options.some((o) => o.value === value));
|
||||||
|
if (known && !kept) out.push(param.key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
useOpsLaunchParams,
|
useOpsLaunchParams,
|
||||||
} from '@web-shared/gov/gov-api.ts';
|
} from '@web-shared/gov/gov-api.ts';
|
||||||
import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts';
|
import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts';
|
||||||
|
import { comboFace, nextAnchors, staleLinked } from './launch-link.ts';
|
||||||
|
import type { ComboFace, LinkAnchors } from './launch-link.ts';
|
||||||
|
|
||||||
export type HomeProps = { tab: string; nav: Nav };
|
export type HomeProps = { tab: string; nav: Nav };
|
||||||
|
|
||||||
@@ -305,8 +307,11 @@ const COMBO_CREATE = '__create__';
|
|||||||
* 选择+新增(2026-08-27 用户定):先摆选择框,「新增 …」是候选**第一项**(2026-08-28 用户定,从末项提上来);
|
* 选择+新增(2026-08-27 用户定):先摆选择框,「新增 …」是候选**第一项**(2026-08-28 用户定,从末项提上来);
|
||||||
* 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单),
|
* 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单),
|
||||||
* 旁边留「返回选择」换回去。
|
* 旁边留「返回选择」换回去。
|
||||||
|
*
|
||||||
|
* 2026-08-31 加联动:候选摆什么由 `face` 说了算(`internal/launch-link.ts` 按服务端下发的分组键算好),
|
||||||
|
* 这里只管渲染 —— 分了组就出 `optgroup`,「新增」那条路**收窄与否都留着**(用户原话)。
|
||||||
*/
|
*/
|
||||||
function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onChange: (v: string) => void }) {
|
function ComboInput({ p, value, face, onChange }: { p: LaunchParam; value: string; face: ComboFace; onChange: (v: string) => void }) {
|
||||||
const [custom, setCustom] = useState(false);
|
const [custom, setCustom] = useState(false);
|
||||||
if (custom) {
|
if (custom) {
|
||||||
return (
|
return (
|
||||||
@@ -340,11 +345,23 @@ function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onC
|
|||||||
>
|
>
|
||||||
<option value="">请选择…</option>
|
<option value="">请选择…</option>
|
||||||
<option value={COMBO_CREATE}>+ 新增{p.label}…</option>
|
<option value={COMBO_CREATE}>+ 新增{p.label}…</option>
|
||||||
{(p.options ?? []).map((o) => (
|
{face.groups.map((g, i) =>
|
||||||
<option key={o.value} value={o.value}>
|
g.label === undefined ? (
|
||||||
{o.label}
|
g.options.map((o) => (
|
||||||
</option>
|
<option key={o.value} value={o.value}>
|
||||||
))}
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<optgroup key={`${g.label}-${i}`} label={g.label}>
|
||||||
|
{g.options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -364,9 +381,18 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
|||||||
const specQ = useOpsLaunchParams(activityId, true);
|
const specQ = useOpsLaunchParams(activityId, true);
|
||||||
const spec = specQ.data?.params ?? [];
|
const spec = specQ.data?.params ?? [];
|
||||||
const [values, setValues] = useState<LaunchValues>({});
|
const [values, setValues] = useState<LaunchValues>({});
|
||||||
|
/* 联动(2026-08-31 用户定):组名 → 坐着锚位的那格;锚自己摆全量,其余格按它的分组键收窄。
|
||||||
|
`linkNote` 是「原来选的那个与新类型不同类,已清空」这句话 —— 人一动那一格就摘掉 */
|
||||||
|
const [anchors, setAnchors] = useState<LinkAnchors>({});
|
||||||
|
const [linkNote, setLinkNote] = useState<Record<string, string>>({});
|
||||||
/* 参数表回来了才铺初始值(含服务端 default 预填)—— 抽屉先开、表后到 */
|
/* 参数表回来了才铺初始值(含服务端 default 预填)—— 抽屉先开、表后到 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (specQ.data !== undefined) setValues(initLaunchValues(specQ.data.params));
|
if (specQ.data === undefined) return;
|
||||||
|
const init = initLaunchValues(specQ.data.params);
|
||||||
|
setValues(init);
|
||||||
|
/* 服务端 default 预填的值也算「填上了」—— 它就是这一组的锚 */
|
||||||
|
setAnchors(nextAnchors(specQ.data.params, init, {}));
|
||||||
|
setLinkNote({});
|
||||||
}, [specQ.data]);
|
}, [specQ.data]);
|
||||||
/* 幂等键:每开一次抽屉(每次挂载)一枚 */
|
/* 幂等键:每开一次抽屉(每次挂载)一枚 */
|
||||||
const [idem] = useState(() => crypto.randomUUID());
|
const [idem] = useState(() => crypto.randomUUID());
|
||||||
@@ -396,7 +422,25 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
|||||||
}, [onCancel]);
|
}, [onCancel]);
|
||||||
|
|
||||||
const setVal = (key: string, v: string | string[]) => {
|
const setVal = (key: string, v: string | string[]) => {
|
||||||
setValues((cur) => ({ ...cur, [key]: v }));
|
/* 联动三步(2026-08-31):重认锚 → 按锚收窄 → 组内失配的值清空并说一句。
|
||||||
|
失配 = 那个值在全量候选里、却不在收窄后的候选里;人自己新增的值不动(它压根不在候选里) */
|
||||||
|
const merged: LaunchValues = { ...values, [key]: v };
|
||||||
|
const nextA = nextAnchors(spec, merged, anchors);
|
||||||
|
const notes: Record<string, string> = {};
|
||||||
|
for (const stale of staleLinked(spec, merged, nextA)) {
|
||||||
|
const was = merged[stale];
|
||||||
|
merged[stale] = '';
|
||||||
|
const kind = comboFace(spec.find((x) => x.key === stale) ?? { key: stale, label: stale, type: 'combo' }, spec, merged, nextA).narrow?.label;
|
||||||
|
notes[stale] = kind === undefined ? `原选的 ${String(was)} 与新的选择不同类,已清空` : `原选的 ${String(was)} 与「${kind}」不同类,已清空`;
|
||||||
|
}
|
||||||
|
setValues(merged);
|
||||||
|
if (nextA !== anchors) setAnchors(nextA);
|
||||||
|
setLinkNote((cur) => {
|
||||||
|
const next = { ...cur, ...notes };
|
||||||
|
/* 人一动这一格,它自己那句「已清空」就摘掉 —— 说的是上一次的事 */
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
/* 改过的格子把它的小红字摘掉 —— 错留在屏上而人已经改了,是另一种撒谎 */
|
/* 改过的格子把它的小红字摘掉 —— 错留在屏上而人已经改了,是另一种撒谎 */
|
||||||
setFieldErr((cur) => {
|
setFieldErr((cur) => {
|
||||||
if (cur[key] === undefined) return cur;
|
if (cur[key] === undefined) return cur;
|
||||||
@@ -509,7 +553,7 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{p.type === 'combo' && <ComboInput p={p} value={(values[p.key] as string) ?? ''} onChange={(v) => setVal(p.key, v)} />}
|
{p.type === 'combo' && <ComboInput p={p} value={(values[p.key] as string) ?? ''} face={comboFace(p, spec, values, anchors)} onChange={(v) => setVal(p.key, v)} />}
|
||||||
{p.type === 'list' && (
|
{p.type === 'list' && (
|
||||||
<span className="g-lplist">
|
<span className="g-lplist">
|
||||||
{((values[p.key] as string[]) ?? ['']).map((v, i, arr) => (
|
{((values[p.key] as string[]) ?? ['']).map((v, i, arr) => (
|
||||||
@@ -537,6 +581,17 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{p.desc !== undefined && <small>{p.desc}</small>}
|
{p.desc !== undefined && <small>{p.desc}</small>}
|
||||||
|
{/* 联动(2026-08-31):被收窄的那格要说清是按谁的什么收的,以及怎么换 —— 悄悄少几条候选比不收窄更糟 */}
|
||||||
|
{p.type === 'combo' &&
|
||||||
|
(() => {
|
||||||
|
const narrow = comboFace(p, spec, values, anchors).narrow;
|
||||||
|
return narrow === undefined ? null : (
|
||||||
|
<small>
|
||||||
|
已按「{narrow.by}」收窄:只列同为「{narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{narrow.by}」,或在这里新增{p.label}。
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{linkNote[p.key] !== undefined && <small className="warn">{linkNote[p.key]}</small>}
|
||||||
{fieldErr[p.key] !== undefined && <small className="warn">{fieldErr[p.key]}</small>}
|
{fieldErr[p.key] !== undefined && <small className="warn">{fieldErr[p.key]}</small>}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+13
-3
@@ -385,7 +385,8 @@ window.addEventListener('message', (e) => {
|
|||||||
"required": true,
|
"required": true,
|
||||||
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
|
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
|
||||||
"placeholder": "LM2596S-5.0",
|
"placeholder": "LM2596S-5.0",
|
||||||
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }]
|
"linkGroup": "chip-type",
|
||||||
|
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "candidateInternalPns",
|
"key": "candidateInternalPns",
|
||||||
@@ -394,7 +395,12 @@ window.addEventListener('message', (e) => {
|
|||||||
"required": true,
|
"required": true,
|
||||||
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
|
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
|
||||||
"placeholder": "XL2596S-5.0",
|
"placeholder": "XL2596S-5.0",
|
||||||
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }, { "value": "LM2596S-ADJ", "label": "LM2596S-ADJ" }]
|
"linkGroup": "chip-type",
|
||||||
|
"options": [
|
||||||
|
{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" },
|
||||||
|
{ "value": "AMS1117-3.3", "label": "AMS1117-3.3", "group": "2", "groupLabel": "LDO 线性稳压器" },
|
||||||
|
{ "value": "OLD-001", "label": "OLD-001", "group": "" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -406,7 +412,11 @@ W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉
|
|||||||
|
|
||||||
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。
|
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。
|
||||||
|
|
||||||
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
|
**按分组联动(2026-08-31 用户定)**:候选可带 `group`(分组键,如这颗料的芯片类型编号;`""` = 那一列没值,即「未标类型」)与 `groupLabel`(它的人读名,字典读不到就没有这个键);参数可带 `linkGroup`(联动组名)。**同一 `linkGroup` 的几格按 `group` 互相收窄**:组内**第一个填上值的那格是锚**,锚自己永远摆全量候选,其余格只列与锚同 `group` 的候选 + `group` 为空的那些(殿后单独一组)+「新增」这条路;锚清空则整组解除收窄(锚**不让位**给还填着值的另一格 —— 否则「清空锚、改个类型重填」当场被另一格锁住),整组都空了才重新认锚。锚没有 `group`、或锚填的是候选之外的新值 → 不收窄。锚换了类型之后,另一格里**在全量候选里、却不在收窄后候选里**的值由前端清空并说明(人自己新增的值不动 —— 它压根不在候选里)。
|
||||||
|
|
||||||
|
> **这是表单的引导,不是墙**:服务端不据 `linkGroup` 校验 —— 两格填不同 `group` 的值照样收得下(combo 本就允许候选之外的值)。类型一致性由活动自己的收步判据管(W05 是 S1 的 `sub_eval_part_distinct_chip_type_id = 1`)。
|
||||||
|
|
||||||
|
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`'x-options'.group` 让候选带上分组键 —— 只写列名(`'chip_type_id'`)= 有键没名,整份写 `{ column, ledger, value, label }` 再从字典表换人读名;`'x-link': '<组名>'` 把几格结成联动组(同组按分组键互相收窄,见上);`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
|
||||||
|
|
||||||
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
|
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,12 @@ export const LAUNCH_PARAM_TYPES = ['text', 'textarea', 'select', 'combo', 'list'
|
|||||||
export const launchParamTypeSchema = z.enum(LAUNCH_PARAM_TYPES);
|
export const launchParamTypeSchema = z.enum(LAUNCH_PARAM_TYPES);
|
||||||
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
||||||
|
|
||||||
export const launchOptionSchema = z.object({ value: z.string(), label: z.string() });
|
/**
|
||||||
|
* 一条候选。`group` 是**联动分组键**(2026-08-31 用户定):清单 `x-options.group` 指了哪一列,
|
||||||
|
* 这颗候选就带上它在那一列的值(如料号的 `chip_type_id`);空串 = 那一列没值(「未标类型」)。
|
||||||
|
* `groupLabel` 是这枚键的人读名(从字典表现读,如「DC-DC 降压稳压器」),读不到就没有 —— 界面退回摆键本身。
|
||||||
|
*/
|
||||||
|
export const launchOptionSchema = z.object({ value: z.string(), label: z.string(), group: z.string().optional(), groupLabel: z.string().optional() });
|
||||||
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
||||||
|
|
||||||
export const launchParamSchema = z.object({
|
export const launchParamSchema = z.object({
|
||||||
@@ -188,6 +193,13 @@ export const launchParamSchema = z.object({
|
|||||||
/** select / combo 的候选;combo 提交候选之外的值也合法 */
|
/** select / combo 的候选;combo 提交候选之外的值也合法 */
|
||||||
options: z.array(launchOptionSchema).optional(),
|
options: z.array(launchOptionSchema).optional(),
|
||||||
default: z.union([z.string(), z.array(z.string())]).optional(),
|
default: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
/**
|
||||||
|
* 联动组名(清单 `x-link`)—— 同一组的几格按候选的 `group` 互相收窄:
|
||||||
|
* 组内**第一个填上值的那格是锚**,锚自己永远摆全量候选,其余格只摆与锚同 `group` 的候选
|
||||||
|
* (外加没有 `group` 的那些,单独一组殿后)+「新增」这条路;锚清空或改成新增即解除。
|
||||||
|
* 服务端**不据此立墙** —— combo 本就收候选之外的值,类型一致性归活动自己的收步判据管。
|
||||||
|
*/
|
||||||
|
linkGroup: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user