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:
@@ -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,
|
||||
} 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 };
|
||||
|
||||
@@ -305,8 +307,11 @@ const COMBO_CREATE = '__create__';
|
||||
* 选择+新增(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);
|
||||
if (custom) {
|
||||
return (
|
||||
@@ -340,11 +345,23 @@ function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onC
|
||||
>
|
||||
<option value="">请选择…</option>
|
||||
<option value={COMBO_CREATE}>+ 新增{p.label}…</option>
|
||||
{(p.options ?? []).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
{face.groups.map((g, i) =>
|
||||
g.label === undefined ? (
|
||||
g.options.map((o) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -364,9 +381,18 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
const specQ = useOpsLaunchParams(activityId, true);
|
||||
const spec = specQ.data?.params ?? [];
|
||||
const [values, setValues] = useState<LaunchValues>({});
|
||||
/* 联动(2026-08-31 用户定):组名 → 坐着锚位的那格;锚自己摆全量,其余格按它的分组键收窄。
|
||||
`linkNote` 是「原来选的那个与新类型不同类,已清空」这句话 —— 人一动那一格就摘掉 */
|
||||
const [anchors, setAnchors] = useState<LinkAnchors>({});
|
||||
const [linkNote, setLinkNote] = useState<Record<string, string>>({});
|
||||
/* 参数表回来了才铺初始值(含服务端 default 预填)—— 抽屉先开、表后到 */
|
||||
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]);
|
||||
/* 幂等键:每开一次抽屉(每次挂载)一枚 */
|
||||
const [idem] = useState(() => crypto.randomUUID());
|
||||
@@ -396,7 +422,25 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
}, [onCancel]);
|
||||
|
||||
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) => {
|
||||
if (cur[key] === undefined) return cur;
|
||||
@@ -509,7 +553,7 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
))}
|
||||
</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' && (
|
||||
<span className="g-lplist">
|
||||
{((values[p.key] as string[]) ?? ['']).map((v, i, arr) => (
|
||||
@@ -537,6 +581,17 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
|
||||
</span>
|
||||
)}
|
||||
{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>}
|
||||
</label>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user