Archived
fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。 查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn (role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 —— sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来 (行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。 平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。 · 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白), 并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去; · 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格; · 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)—— 只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。 W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。 测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过; 四道门禁绿(2548 测)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -293,3 +293,61 @@ describe('x-options.group / x-link', () => {
|
||||
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 互斥组(2026-08-31 用户定:「不应该能选到同样的两个芯片」)。
|
||||
* 与 `x-link` 正相反 —— **这一条是墙**:W05 两格同一颗料会把单子做死(两行撞同一个主键收成一行,
|
||||
* S1 的 `exists_status_found = 2` 永不成立,而件上那两行人加不出来),而唯一能改的时机就是起单这一刻。
|
||||
*/
|
||||
describe('x-distinct', () => {
|
||||
const DISTINCT = {
|
||||
type: 'object',
|
||||
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||
properties: {
|
||||
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 1 },
|
||||
candidateInternalPn: { type: 'string', title: '候选内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 2 },
|
||||
/* 同一张表上不在这一组的格子:填成一样也与它无关 */
|
||||
note: { type: 'string', title: '备注' },
|
||||
},
|
||||
};
|
||||
const paramsOf = async () => launchParamsOf(DISTINCT, async () => [{ value: 'LM2596S-5.0', label: 'LM2596S-5.0' }, { value: 'XL2596S-5.0', label: 'XL2596S-5.0' }]);
|
||||
|
||||
it('读得到组名;**不以有候选为前提** —— 自由输入的两格同样归它管(墙管的是值,不是候选)', async () => {
|
||||
expect(fieldSpecsOf(DISTINCT).map((s) => s.distinctGroup)).toEqual(['part', 'part', undefined]);
|
||||
const bare = await launchParamsOf({ properties: { a: { type: 'string', 'x-distinct': 'g' }, b: { type: 'string', 'x-distinct': '' } } }, noResolve);
|
||||
expect(bare.map((p) => [p.type, p.distinctGroup])).toEqual([
|
||||
['text', 'g'],
|
||||
['text', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('两格同一个值 → 拒,点名**后填的那格**,话里指向它撞上了谁', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out).toEqual({ ok: false, issues: [{ key: 'candidateInternalPn', message: '与「原内部料号」填的是同一个 —— 这两格不能一样,换一个再启动' }] });
|
||||
});
|
||||
|
||||
it('**去空白 + 不分大小写**:只挡精确相等的话,`lm2596s-5.0` 会照样进去,混一颗垃圾主数据出来', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('两颗不同的料照收;不在这一组的格子填成一样也不管', async () => {
|
||||
const out = validateLaunchParams(
|
||||
await paramsOf(),
|
||||
{ originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' },
|
||||
fieldSpecsOf(DISTINCT),
|
||||
);
|
||||
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' } });
|
||||
});
|
||||
|
||||
it('空的不算撞上 —— 两格都没填该报的是必填,不是「填了同一个」', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: '', candidateInternalPn: '' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out).toEqual({ ok: false, issues: [{ key: 'originalInternalPn', message: '必填' }, { key: 'candidateInternalPn', message: '必填' }] });
|
||||
});
|
||||
|
||||
it('数组格不参与(契约写死的边界:本期没有这种形状)', async () => {
|
||||
const arrays = { properties: { a: { type: 'array', items: { type: 'string' }, title: 'A', 'x-distinct': 'g' }, b: { type: 'array', items: { type: 'string' }, title: 'B', 'x-distinct': 'g' } } };
|
||||
const out = validateLaunchParams(await launchParamsOf(arrays, noResolve), { a: ['X'], b: ['X'] }, fieldSpecsOf(arrays));
|
||||
expect(out).toEqual({ ok: true, data: { a: ['X'], b: ['X'] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
|
||||
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
|
||||
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
|
||||
* · `x-distinct`:互斥组名 —— 同名的几格**不能填同一个值**。这条**是墙**(与 `x-link` 正相反):
|
||||
* 前端不把别的格占了的候选列出来,自己写进来的由这里拒。理由见契约 `distinctGroup` 的注释:
|
||||
* W05 两格同一颗料会把单子做死,而唯一能改的时机就是起单这一刻
|
||||
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||
@@ -19,7 +22,7 @@
|
||||
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
||||
*/
|
||||
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||
import { LAUNCH_PARAM_TYPES } from '@contracts';
|
||||
import { LAUNCH_PARAM_TYPES, launchDistinctKey, launchDistinctMessage } from '@contracts';
|
||||
|
||||
/**
|
||||
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
|
||||
@@ -46,6 +49,8 @@ export type FieldSpec = {
|
||||
readonly optionsSource: OptionsSource | undefined;
|
||||
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
|
||||
readonly linkGroup: string | undefined;
|
||||
/** 互斥组名(`x-distinct`);同名的几格不能填同一个值 —— 这一条服务端真拦 */
|
||||
readonly distinctGroup: string | undefined;
|
||||
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||
readonly order: number | undefined;
|
||||
};
|
||||
@@ -112,6 +117,7 @@ export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): Fi
|
||||
defaultValue: spec['default'],
|
||||
optionsSource: optionsSourceOf(spec['x-options']),
|
||||
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
|
||||
distinctGroup: asString(spec['x-distinct']) === '' ? undefined : asString(spec['x-distinct']),
|
||||
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||
};
|
||||
});
|
||||
@@ -192,6 +198,8 @@ export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[]
|
||||
...(fallback === undefined ? {} : { default: fallback }),
|
||||
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
|
||||
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
|
||||
/* 互斥**不以有候选为前提**:两格都自己写、写成同一个,照样要拦 —— 墙管的是值,不是候选 */
|
||||
...(spec.distinctGroup === undefined ? {} : { distinctGroup: spec.distinctGroup }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,9 +238,34 @@ function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unkn
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一互斥组(`x-distinct`)里撞了同一个值的那几格 —— 点名的是**后填的那格**(表单次序在后的),
|
||||
* 话里指向它撞上了谁。只认标量:数组格不参与(见契约 `distinctGroup` 的注释)。
|
||||
* 「算不算同一个」由 `launchDistinctKey` 说了算 —— 前端用的是同一把尺。
|
||||
*/
|
||||
function distinctIssuesOf(params: readonly LaunchParam[], data: Readonly<Record<string, unknown>>): LaunchIssue[] {
|
||||
const issues: LaunchIssue[] = [];
|
||||
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
|
||||
for (const param of params) {
|
||||
const group = param.distinctGroup;
|
||||
const value = data[param.key];
|
||||
if (group === undefined || value === undefined || value === null || typeof value === 'object') continue;
|
||||
const key = launchDistinctKey(String(value));
|
||||
if (key === '') continue;
|
||||
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
|
||||
seenPerGroup.set(group, seen);
|
||||
const first = seen.get(key);
|
||||
if (first === undefined) seen.set(key, param);
|
||||
else issues.push({ key: param.key, message: launchDistinctMessage(first.label) });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
||||
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
||||
* **互斥组(`x-distinct`)是这里唯一的跨格校验**:同组两格填了同一个值就拒 —— 前端不列出别人占了的
|
||||
* 候选只是礼貌,自己写一个同样的值、或压根不经这个表单起单(脚本 / API),拦住的得是这里。
|
||||
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
||||
*/
|
||||
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
||||
@@ -287,5 +320,6 @@ export function validateLaunchParams(params: readonly LaunchParam[], input: Laun
|
||||
}
|
||||
data[param.key] = coerced.value;
|
||||
}
|
||||
issues.push(...distinctIssuesOf(params, data));
|
||||
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 启动参数的按分组联动(2026-08-31 用户定:原内部料号 / 候选内部料号按芯片类型互相收窄)。
|
||||
* 启动参数里格与格之间的两件事(2026-08-31 用户定,W05 的原料号 / 候选料号两格两样都有):
|
||||
* · **按分组联动**(按芯片类型互相收窄)—— 引导,三条口径见下;
|
||||
* · **互斥**(不能选到同一颗料)—— 墙,末尾那一个 describe。
|
||||
*
|
||||
* 三条口径由用户当场定死:
|
||||
* 联动的三条口径由用户当场定死:
|
||||
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除
|
||||
* (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。
|
||||
* ②未标类型的老料**列在末尾单独一组**,不是不列。
|
||||
@@ -9,7 +11,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LaunchParam } from '@contracts';
|
||||
import { comboFace, nextAnchors, staleLinked } from '../internal/launch-link.ts';
|
||||
import { comboFace, distinctIssues, nextAnchors, staleLinked } from '../internal/launch-link.ts';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||
@@ -123,3 +125,65 @@ describe('staleLinked —— 锚换了类型,另一格里失配的值', () => {
|
||||
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: '' }, { 'chip-type': 'originalInternalPn' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动是两件事:
|
||||
* 那个是引导(收窄),**这个是墙**(别的格占了的候选这里不列;自己写一个同样的就按不下启动)。
|
||||
* W05 那两格**两样都声明了**,所以最后一条专测合起来是什么样。
|
||||
*/
|
||||
describe('互斥:别的格占走的候选不列', () => {
|
||||
const both = (key: string, label: string): LaunchParam => ({ ...combo(key, label), distinctGroup: 'part' });
|
||||
const W05_PARAMS: LaunchParam[] = [both('originalInternalPn', '原内部料号'), both('candidateInternalPn', '候选内部料号')];
|
||||
const w05Face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
|
||||
comboFace(W05_PARAMS.find((p) => p.key === key)!, [...W05_PARAMS, LOOSE], values, anchors);
|
||||
|
||||
it('原料号选了 LM2596S-5.0 → 候选那格不再列它,并说清是谁占了', () => {
|
||||
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' });
|
||||
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
|
||||
expect(f.taken).toEqual([{ by: '原内部料号', value: 'LM2596S-5.0' }]);
|
||||
});
|
||||
|
||||
it('**反过来也一样**(不分先后,谁占了谁的都不列)—— 候选先填,原料号那格就少它一条', () => {
|
||||
const f = w05Face('originalInternalPn', { candidateInternalPn: 'XL2596S-5.0' });
|
||||
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
|
||||
});
|
||||
|
||||
it('**自己此刻填着的那个永远留着** —— 抽掉正在显示的值,下拉当场变空白,人看着像被谁清空了', () => {
|
||||
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' });
|
||||
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toContain('LM2596S-5.0');
|
||||
});
|
||||
|
||||
it('不在互斥组里的格子(没有 distinctGroup)谁也不管', () => {
|
||||
const f = comboFace(LOOSE, [...W05_PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0', customerCode: 'CU-0142' }, {});
|
||||
expect(f.taken).toEqual([]);
|
||||
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
|
||||
});
|
||||
|
||||
it('**联动与互斥合起来**(W05 的真形状):按锚的类型收窄之后,锚占着的那颗照样不列', () => {
|
||||
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
|
||||
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
|
||||
// 收窄留下 LM / XL / OLD-001,互斥再抽掉锚占着的 LM
|
||||
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
|
||||
['DC-DC 降压稳压器', ['XL2596S-5.0']],
|
||||
['未标类型', ['OLD-001']],
|
||||
]);
|
||||
});
|
||||
|
||||
describe('distinctIssues —— 还能撞上的那条路(自己「新增」写了一个同样的)', () => {
|
||||
it('点名**后填的那格**,说清撞上了谁', () => {
|
||||
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'BRAND-NEW', candidateInternalPn: 'BRAND-NEW' })).toEqual([
|
||||
{ key: 'candidateInternalPn', by: '原内部料号' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('去空白、不分大小写 —— 与服务端同一把尺', () => {
|
||||
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('两颗不同的、空的、不同组的 —— 一个都不算撞上', () => {
|
||||
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' })).toEqual([]);
|
||||
expect(distinctIssues(W05_PARAMS, { originalInternalPn: '', candidateInternalPn: '' })).toEqual([]);
|
||||
expect(distinctIssues([...W05_PARAMS, LOOSE], { originalInternalPn: 'CU-0142', customerCode: 'CU-0142' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* 启动参数的**按分组联动**(2026-08-31 用户定)—— 纯函数,不碰 React。
|
||||
* 启动参数里**格与格之间**的那些事(2026-08-31 用户定)—— 纯函数,不碰 React。两件,别混:
|
||||
* · **按分组联动**(`linkGroup`):按候选的分组键互相收窄 —— 引导,填了别的照收;
|
||||
* · **互斥**(`distinctGroup`):同组的几格不能是同一个值 —— **墙**,别的格占了的候选这里不列,
|
||||
* 自己写一个同样的值就按不下「启动活动」(真正拦住它的是服务端 `validateLaunchParams`)。
|
||||
*
|
||||
* 服务端下发的两样东西就是这里的全部依据:候选各自带 `group` / `groupLabel`(清单 `x-options.group`),
|
||||
* 参数带 `linkGroup`(清单 `x-link`)。前端**不认识**「原内部料号」「芯片类型」这些名字 ——
|
||||
@@ -13,16 +16,22 @@
|
||||
* ②锚没有分组键(未标类型的老料)、或锚填的是库里没有的新值(新增料号)→ 不收窄。
|
||||
* ③收窄之后,「未标类型」的候选**列在末尾单独一组**,不是不列。
|
||||
*
|
||||
* 服务端不据此立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管。
|
||||
* **联动**这一件服务端不立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管;
|
||||
* **互斥**那一件服务端真拦(同一把尺 `launchDistinctKey`)—— 这里少列几条只是不让人走到那一步。
|
||||
*/
|
||||
import type { LaunchOption, LaunchParam } from '@contracts';
|
||||
import { launchDistinctKey } 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 };
|
||||
/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有)+ 被别的格占走、这里不列的那几个 */
|
||||
export type ComboFace = {
|
||||
readonly groups: readonly OptionGroup[];
|
||||
readonly narrow: { readonly by: string; readonly label: string } | undefined;
|
||||
readonly taken: readonly { readonly by: string; readonly value: string }[];
|
||||
};
|
||||
|
||||
const UNGROUPED = '未标类型';
|
||||
|
||||
@@ -95,22 +104,75 @@ function groupOptions(options: readonly LaunchOption[]): readonly OptionGroup[]
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量 */
|
||||
/** 同一互斥组里**别的格**此刻占着的值(空的不算);自己那格不算自己占的 */
|
||||
function takenByOthers(
|
||||
param: LaunchParam,
|
||||
params: readonly LaunchParam[],
|
||||
values: Readonly<Record<string, string | readonly string[]>>,
|
||||
): readonly { readonly by: string; readonly value: string }[] {
|
||||
const group = param.distinctGroup;
|
||||
if (group === undefined) return [];
|
||||
const out: { by: string; value: string }[] = [];
|
||||
for (const other of params) {
|
||||
if (other.key === param.key || other.distinctGroup !== group) continue;
|
||||
const value = valueOf(values, other.key);
|
||||
if (value !== '') out.push({ by: other.label, value });
|
||||
}
|
||||
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 taken = takenByOthers(param, params, values);
|
||||
const mine = launchDistinctKey(valueOf(values, param.key));
|
||||
const takenKeys = new Set(taken.map((t) => launchDistinctKey(t.value)));
|
||||
/*
|
||||
* 互斥:别的格占了的候选这里不列 —— **唯独自己此刻填着的那个永远留着**。
|
||||
* 抽掉正在显示的值,`<select>` 当场变空白,人看着像被谁清空了;真撞上了该说的是那句话
|
||||
* (`distinctIssues` → 小红字 + 按不下启动),不是让画面自己变魔术。
|
||||
*/
|
||||
const all = (param.options ?? []).filter((o) => launchDistinctKey(o.value) === mine || !takenKeys.has(launchDistinctKey(o.value)));
|
||||
const face = (options: readonly LaunchOption[], narrow: ComboFace['narrow']): ComboFace => ({ groups: groupOptions(options), narrow, taken });
|
||||
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 };
|
||||
if (group === undefined || seated === undefined || seated === param.key) return face(all, 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 };
|
||||
if (anchor === undefined || by === undefined) return face(all, undefined);
|
||||
// 收窄:同分组键的 + 未标类型的(殿后)—— 「新增」那条路由控件自己一直留着
|
||||
return { groups: groupOptions(all.filter((o) => o.group === by.key || o.group === undefined || o.group === '')), narrow: { by: anchor.label, label: by.label } };
|
||||
return face(all.filter((o) => o.group === by.key || o.group === undefined || o.group === ''), { by: anchor.label, label: by.label });
|
||||
}
|
||||
|
||||
/**
|
||||
* 撞了同一个值的那几格 —— 点名**后填的那格**(表单次序在后的),`by` 是它撞上了谁。
|
||||
* 与服务端 `distinctIssuesOf` 同一条规矩、同一把尺(`launchDistinctKey`):这里管「按不下启动」,
|
||||
* 那里管「拦住」—— 两道都要,既不让人填完一屏才被打回来,也不指望前端自觉。
|
||||
*
|
||||
* 下拉里已经不列别人占的值了,还会撞上的只剩两条路:自己「新增」写了一个同样的、或者这格压根是自由输入。
|
||||
*/
|
||||
export function distinctIssues(
|
||||
params: readonly LaunchParam[],
|
||||
values: Readonly<Record<string, string | readonly string[]>>,
|
||||
): readonly { readonly key: string; readonly by: string }[] {
|
||||
const out: { key: string; by: string }[] = [];
|
||||
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
|
||||
for (const param of params) {
|
||||
const group = param.distinctGroup;
|
||||
if (group === undefined) continue;
|
||||
const key = launchDistinctKey(valueOf(values, param.key));
|
||||
if (key === '') continue;
|
||||
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
|
||||
seenPerGroup.set(group, seen);
|
||||
const first = seen.get(key);
|
||||
if (first === undefined) seen.set(key, param);
|
||||
else out.push({ key: param.key, by: first.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
authTicketRoute,
|
||||
controlTaskRoute,
|
||||
disableOpsActivityRoute,
|
||||
launchDistinctMessage,
|
||||
launchOpsInstanceRoute,
|
||||
meRoute,
|
||||
reassignTaskRoute,
|
||||
@@ -52,7 +53,7 @@ import {
|
||||
useOpsLaunchParams,
|
||||
} 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 { comboFace, distinctIssues, nextAnchors, staleLinked } from './launch-link.ts';
|
||||
import type { ComboFace, LinkAnchors } from './launch-link.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
@@ -449,7 +450,14 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const invalid = specQ.data === undefined || ownerId === '' || spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? (p.type === 'list' ? [] : '')));
|
||||
/* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」):同组两格填了同一个值 —— 点名后填的那格,启动按不下去。
|
||||
下拉里已经不列别人占的值,还能撞上的只剩「新增」自己写的那条路;真正拦住它的是服务端(脚本 / API 一样过不去) */
|
||||
const dupeOf: Readonly<Record<string, string>> = Object.fromEntries(distinctIssues(spec, values).map((d) => [d.key, d.by]));
|
||||
const invalid =
|
||||
specQ.data === undefined ||
|
||||
ownerId === '' ||
|
||||
Object.keys(dupeOf).length > 0 ||
|
||||
spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? (p.type === 'list' ? [] : '')));
|
||||
|
||||
const launch = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -529,7 +537,10 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
</button>
|
||||
</Note>
|
||||
)}
|
||||
{spec.map((p) => (
|
||||
{spec.map((p) => {
|
||||
/* combo 的面孔(候选摆什么 · 被谁按类型收窄 · 别的格占走了哪几个)一格算一次 —— 下面几处都读它 */
|
||||
const face = p.type === 'combo' ? comboFace(p, spec, values, anchors) : undefined;
|
||||
return (
|
||||
<label key={p.key} className="g-fld">
|
||||
<span>
|
||||
{p.label}
|
||||
@@ -553,7 +564,7 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{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 === 'combo' && face !== undefined && <ComboInput p={p} value={(values[p.key] as string) ?? ''} face={face} onChange={(v) => setVal(p.key, v)} />}
|
||||
{p.type === 'list' && (
|
||||
<span className="g-lplist">
|
||||
{((values[p.key] as string[]) ?? ['']).map((v, i, arr) => (
|
||||
@@ -582,19 +593,21 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})()}
|
||||
{face?.narrow !== undefined && (
|
||||
<small>
|
||||
已按「{face.narrow.by}」收窄:只列同为「{face.narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{face.narrow.by}」,或在这里新增{p.label}。
|
||||
</small>
|
||||
)}
|
||||
{/* 互斥(2026-08-31 用户定):别的格占了的候选这里不列 —— 少了几条同样要说出来,同一条道理 */}
|
||||
{face !== undefined && face.taken.length > 0 && (
|
||||
<small>{face.taken.map((t) => `「${t.by}」已经选了 ${t.value}`).join('、')} —— 这一格不再列它,两格不能是同一个。</small>
|
||||
)}
|
||||
{dupeOf[p.key] !== undefined && <small className="warn">{launchDistinctMessage(dupeOf[p.key] ?? '')}</small>}
|
||||
{linkNote[p.key] !== undefined && <small className="warn">{linkNote[p.key]}</small>}
|
||||
{fieldErr[p.key] !== undefined && <small className="warn">{fieldErr[p.key]}</small>}
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user