Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 79 additions & 29 deletions src/components/OptionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
ChevronRight,
} from 'lucide-react';
import { getInterfaceLangKey } from '@/i18n';
import { findSwitchCase } from '@/utils/optionHelpers';
import { getSelectedCasesWithOptions } from '@/utils/optionHelpers';
import { getCheckboxMaxCount, getCheckboxMinCount } from '@/utils/checkboxOptionValidation';
import { stripInlineRichText } from '@/utils/richText';
import { SwitchButton, TextInput, FileInput, TimeInput, HotkeyInput } from './FormControls';
Expand All @@ -40,6 +40,13 @@ export function switchHasNestedOptions(optionDef: OptionDefinition): boolean {
return optionDef.cases.some((c: CaseItem) => c.option && c.option.length > 0);
}

/** 嵌套子选项分组:checkbox 多选时,不同 case 的子项需要分开渲染才能区分归属 */
interface NestedOptionGroup {
caseName: string;
label: string;
optionKeys: string[];
}

/** 子选项折叠箭头:复用任务标题的 ChevronRight 样式,位于开关/下拉框左侧 */
function OptionCollapseArrow({
collapsed,
Expand Down Expand Up @@ -476,24 +483,18 @@ export function OptionEditor({
: undefined;
const effectiveDisabled = disabled || isOptionIncompatible;

// 获取当前选中的 case(用于渲染嵌套选项)
const getSelectedCase = (): CaseItem | undefined => {
if (optionDef.type === 'switch') {
const isChecked = effectiveValue?.type === 'switch' ? effectiveValue.value : false;
return findSwitchCase(optionDef.cases, isChecked);
}
if (optionDef.type === 'select' || !optionDef.type) {
const caseName =
effectiveValue?.type === 'select'
? effectiveValue.caseName
: optionDef.default_case || optionDef.cases?.[0]?.name;
return optionDef.cases?.find((c) => c.name === caseName);
}
return undefined;
};
// 嵌套子选项分组:switch/select 单选取当前 case;checkbox 为多选,
// 协议允许任意 case 挂 option,故按被选中的 case 逐组汇总(每组保留标签用于区分来源)
const toNestedGroup = (caseItem: CaseItem): NestedOptionGroup => ({
caseName: caseItem.name,
label: isMxuOption
? t(caseItem.label || caseItem.name)
: resolveI18nText(caseItem.label, langKey) || caseItem.name,
optionKeys: caseItem.option ?? [],
});

const selectedCase = getSelectedCase();
const nestedOptionKeys = selectedCase?.option || [];
const nestedGroups = getSelectedCasesWithOptions(optionDef, effectiveValue).map(toNestedGroup);
const nestedOptionKeys = nestedGroups.flatMap((g) => g.optionKeys);

// 当前选项的子选项是否处于折叠状态(缺省展开)
const isCollapsed = globalScope ? localCollapsed : !!collapsedOptions?.[optionKey];
Expand Down Expand Up @@ -628,17 +629,28 @@ export function OptionEditor({
isOptionIncompatible && 'opacity-60',
)}
>
<OptionLabelWithIncompatible
label={optionLabel}
icon={optionDef.icon}
basePath={basePath}
incompatibleReason={incompatibleReason}
/>
<OptionDescription
description={optionDescription}
basePath={basePath}
translations={translations}
/>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 max-w-[60%]">
<OptionLabelWithIncompatible
label={optionLabel}
icon={optionDef.icon}
basePath={basePath}
incompatibleReason={incompatibleReason}
/>
<OptionDescription
description={optionDescription}
basePath={basePath}
translations={translations}
/>
</div>
{nestedOptionKeys.length > 0 && (
<OptionCollapseArrow
collapsed={isCollapsed}
onToggle={handleToggleCollapsed}
disabled={effectiveDisabled}
/>
)}
</div>
<div className="grid grid-cols-4 gap-1">
{optionDef.cases.map((caseItem) => {
const caseLabel = isMxuOption
Expand Down Expand Up @@ -710,6 +722,44 @@ export function OptionEditor({
</span>
</div>
)}
{/* 渲染嵌套选项(可折叠,复用任务标题的 grid 展开动画)。
checkbox 多选时同一层可能挂多组子项,按 case 分组并标注归属,避免来源混淆 */}
{nestedOptionKeys.length > 0 && (
<div
className="grid transition-[grid-template-rows] duration-150 ease-out"
style={{ gridTemplateRows: isCollapsed ? '0fr' : '1fr' }}
>
<div className={clsx('min-h-0', isCollapsed ? 'overflow-hidden' : 'overflow-visible')}>
<div className="space-y-3">
{nestedGroups.map((group) => (
<div key={group.caseName} className="space-y-3">
{nestedGroups.length > 1 && (
<div className="text-xs text-text-muted">
<span className="inline-block max-w-full truncate rounded bg-bg-hover px-1.5 py-0.5">
<RichLabel text={group.label} />
</span>
</div>
)}
{group.optionKeys.map((nestedKey) => (
<OptionEditor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 高 · 嵌套编辑不会影响输出

当选中的复选框分支包含嵌套选项,且这些选项的值应影响任务流水线或前置任务时,collectOptionOverrides 和 generateTaskPipelineOverride 只添加所选分支自身的覆盖值,而 collectPretaskOptionValues 会在复选框处返回;子选项的值因此未包含在任务和前置任务的输出中。

请让流水线覆盖值和前置任务收集器递归遍历所选分支的选项键。

另请查看 src/components/OptionEditor.tsx:743。

面向 AI 代理的提示
在 `src/components/OptionEditor.tsx` 第 744 行:

**嵌套编辑不会影响输出**

当选中的复选框分支包含嵌套选项,且这些选项的值应影响任务流水线或前置任务时,`collectOptionOverrides` 和 `generateTaskPipelineOverride` 只添加所选分支自身的覆盖值,而 `collectPretaskOptionValues` 会在复选框处返回;子选项的值因此未包含在任务和前置任务的输出中。

请让流水线覆盖值和前置任务收集器递归遍历所选分支的选项键。

另请查看 `src/components/OptionEditor.tsx:743`。
Original comment in English

🟠 High · Nested edits never reach outputs

When a selected checkbox case has nested options whose values should affect a task pipeline or pretask, collectOptionOverrides and generateTaskPipelineOverride add only the selected case’s own override, while collectPretaskOptionValues returns at the checkbox; child values are omitted from task and pretask output.

Make the pipeline override and pretask collectors recurse into the selected cases’ option keys.

Also at src/components/OptionEditor.tsx:743.

Prompt for AI agents
In `src/components/OptionEditor.tsx` at line 744:

**Nested edits never reach outputs**

When a selected checkbox case has nested options whose values should affect a task pipeline or pretask, `collectOptionOverrides` and `generateTaskPipelineOverride` add only the selected case’s own override, while `collectPretaskOptionValues` returns at the checkbox; child values are omitted from task and pretask output.

Make the pipeline override and pretask collectors recurse into the selected cases’ option keys.

Also at `src/components/OptionEditor.tsx:743`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 中 · 无效的嵌套选择未通过校验

当选中的复选框分支包含一个嵌套复选框,而其选择违反了配置的数量限制时,validateOptionKeys 在检查外层复选框后就会返回,不会遍历所选分支的选项键。因此,validateInstanceCheckboxCounts 会漏掉嵌套选项的数量限制违规,程序启动时仍会采用无效选择。

请让复选框校验递归遍历所选分支的选项键。

面向 AI 代理的提示
在 `src/components/OptionEditor.tsx` 第 744 行:

**无效的嵌套选择未通过校验**

当选中的复选框分支包含一个嵌套复选框,而其选择违反了配置的数量限制时,`validateOptionKeys` 在检查外层复选框后就会返回,不会遍历所选分支的选项键。因此,`validateInstanceCheckboxCounts` 会漏掉嵌套选项的数量限制违规,程序启动时仍会采用无效选择。

请让复选框校验递归遍历所选分支的选项键。
Original comment in English

🟡 Medium · Invalid nested selections pass validation

When a selected checkbox case contains a nested checkbox whose selection violates its configured count limits, validateOptionKeys returns after checking the outer checkbox without visiting its selected cases’ option keys, so validateInstanceCheckboxCounts misses the nested count violation and startup proceeds with an invalid selection.

Make checkbox validation recurse into the option keys of selected cases.

Prompt for AI agents
In `src/components/OptionEditor.tsx` at line 744:

**Invalid nested selections pass validation**

When a selected checkbox case contains a nested checkbox whose selection violates its configured count limits, `validateOptionKeys` returns after checking the outer checkbox without visiting its selected cases’ option keys, so `validateInstanceCheckboxCounts` misses the nested count violation and startup proceeds with an invalid selection.

Make checkbox validation recurse into the option keys of selected cases.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 中 · 循环引用的选项会导致渲染崩溃

当选中复选框分支中的某个嵌套选项引用了祖先路径上已有的选项时,OptionEditor 会递归渲染被引用的子项,却不跟踪已访问的选项键。如果某个选中的复选框分支直接或通过另一个复选框引用自身,就会导致组件不断递归渲染,直到界面发生堆栈溢出,而不是安全地显示该选项。

请在递归渲染期间跟踪祖先选项键,并在渲染重复选项之前停止递归或报告循环引用。

面向 AI 代理的提示
在 `src/components/OptionEditor.tsx` 第 744 行:

**循环引用的选项会导致渲染崩溃**

当选中复选框分支中的某个嵌套选项引用了祖先路径上已有的选项时,`OptionEditor` 会递归渲染被引用的子项,却不跟踪已访问的选项键。如果某个选中的复选框分支直接或通过另一个复选框引用自身,就会导致组件不断递归渲染,直到界面发生堆栈溢出,而不是安全地显示该选项。

请在递归渲染期间跟踪祖先选项键,并在渲染重复选项之前停止递归或报告循环引用。
Original comment in English

🟡 Medium · Cyclic options crash rendering

When a selected checkbox case’s nested option references an option already on its ancestor path, OptionEditor recursively renders the referenced child without tracking visited option keys. A selected checkbox case that refers back to itself (directly or through another checkbox) causes recursive component rendering until the UI hits a stack overflow instead of displaying the option safely.

Track ancestor option keys during recursive rendering and stop or report a cycle before rendering the repeated option.

Prompt for AI agents
In `src/components/OptionEditor.tsx` at line 744:

**Cyclic options crash rendering**

When a selected checkbox case’s nested option references an option already on its ancestor path, `OptionEditor` recursively renders the referenced child without tracking visited option keys. A selected checkbox case that refers back to itself (directly or through another checkbox) causes recursive component rendering until the UI hits a stack overflow instead of displaying the option safely.

Track ancestor option keys during recursive rendering and stop or report a cycle before rendering the repeated option.

Comment on lines +743 to +744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 中 · 共享设置会重复显示

当两个同时选中的复选框分支引用同一个嵌套选项键时,nestedGroups.map 会为每个引用都渲染一个编辑器,而每个编辑器都会读取和写入同一个 allOptionValues[nestedKey];用户会看到同一设置的多个控件,修改其中任意一个都会同时影响其他控件。

请为每个唯一的嵌套选项键只渲染一个编辑器。

面向 AI 代理的提示
在 `src/components/OptionEditor.tsx` 第 743–744 行:

**共享设置会重复显示**

当两个同时选中的复选框分支引用同一个嵌套选项键时,`nestedGroups.map` 会为每个引用都渲染一个编辑器,而每个编辑器都会读取和写入同一个 `allOptionValues[nestedKey]`;用户会看到同一设置的多个控件,修改其中任意一个都会同时影响其他控件。

请为每个唯一的嵌套选项键只渲染一个编辑器。
Original comment in English

🟡 Medium · Shared settings appear twice

When two simultaneously selected checkbox cases reference the same nested option key, nestedGroups.map renders an editor for each reference, and each editor reads and writes the same allOptionValues[nestedKey]; callers see duplicate controls for one setting, and changing either changes both.

Render only one editor for each unique nested option key.

Prompt for AI agents
In `src/components/OptionEditor.tsx` at lines 743-744:

**Shared settings appear twice**

When two simultaneously selected checkbox cases reference the same nested option key, `nestedGroups.map` renders an editor for each reference, and each editor reads and writes the same `allOptionValues[nestedKey]`; callers see duplicate controls for one setting, and changing either changes both.

Render only one editor for each unique nested option key.

key={`${group.caseName}:${nestedKey}`}
instanceId={instanceId}
taskId={taskId}
optionKey={nestedKey}
value={allOptionValues[nestedKey]}
depth={depth + 1}
disabled={effectiveDisabled}
globalScope={globalScope}
controllerIncompatible={isOptionIncompatible}
parentIncompatibilityReason={incompatibleReasonType}
/>
))}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
Expand Down
41 changes: 40 additions & 1 deletion src/utils/optionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* 选项相关的工具函数
*/

import type { CaseItem } from '@/types/interface';
import type { CaseItem, OptionDefinition, OptionValue } from '@/types/interface';

const YES_CASE_NAMES = ['Yes', 'yes', 'Y', 'y'] as const;

Expand All @@ -23,3 +23,42 @@ export function findSwitchCase(
const targetNames: readonly string[] = isChecked ? YES_CASE_NAMES : NO_CASE_NAMES;
return cases.find((c) => targetNames.includes(c.name));
}

/**
* 找出「当前选中且带子选项」的 case 列表,供渲染嵌套 option 使用。
*
* - switch / select:单选,最多返回 1 个
* - checkbox:多选,可能返回多个(按 cases 定义顺序,保证渲染顺序稳定)
* - input / hotkey:无 case,返回空数组
*
* 只返回 option 非空的 case,调用方无需再判断。
*/
export function getSelectedCasesWithOptions(
optionDef: OptionDefinition,
value: OptionValue | undefined,
): CaseItem[] {
const hasNestedOption = (caseItem: CaseItem) => (caseItem.option?.length ?? 0) > 0;

if (optionDef.type === 'checkbox') {
const selectedCases =
value?.type === 'checkbox' ? value.caseNames : (optionDef.default_case ?? []);
return optionDef.cases.filter((c) => selectedCases.includes(c.name) && hasNestedOption(c));
}

if (optionDef.type === 'switch') {
const isChecked = value?.type === 'switch' ? value.value : false;
const matched = findSwitchCase(optionDef.cases, isChecked);
return matched && hasNestedOption(matched) ? [matched] : [];
}

if (optionDef.type === 'select' || !optionDef.type) {
const caseName =
value?.type === 'select'
? value.caseName
: optionDef.default_case || optionDef.cases?.[0]?.name;
const matched = optionDef.cases?.find((c) => c.name === caseName);
return matched && hasNestedOption(matched) ? [matched] : [];
}

return [];
}
190 changes: 190 additions & 0 deletions tests/optionHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* 嵌套子选项推导的单测。
*
* 背景:ProjectInterface 协议允许 case.option 挂子配置项(最大嵌套无限层)。
* switch / select 是单选,取当前选中的 case 即可;checkbox 是多选,
* 任意被勾选的 case 都可能带子项,必须逐个汇总 —— 早期实现按单选处理,
* 导致 checkbox 下的子配置项永远推导不出来。
*
* 被测模块只有 type-only import,运行时无需 DOM / Tauri 环境。
*/

import { test } from 'node:test';
import assert from 'node:assert/strict';

import { getSelectedCasesWithOptions, findSwitchCase } from '../src/utils/optionHelpers.ts';

type CaseItem = {
name: string;
label?: string;
option?: string[];
};

function checkbox(cases: CaseItem[], default_case?: string[]) {
return { type: 'checkbox' as const, cases, default_case };
}

test('checkbox:勾选一个带子项的 case 时返回它', () => {
const optionDef = checkbox([
{ name: '节点2舰种1', option: ['节点2检测目标名', '节点2反向检测目标'] },
{ name: '节点2舰种2', option: ['节点2检测目标名2'] },
]);
const result = getSelectedCasesWithOptions(optionDef, {
type: 'checkbox',
caseNames: ['节点2舰种1'],
});
assert.deepEqual(
result.map((c) => c.name),
['节点2舰种1'],
);
});

test('checkbox:同时勾选多个带子项的 case 时全部返回,且保持定义顺序', () => {
const optionDef = checkbox([
{ name: 'A', option: ['a1'] },
{ name: 'B', option: ['b1'] },
{ name: 'C', option: ['c1'] },
]);
// 故意让勾选顺序与定义顺序相反,返回值仍应按定义顺序
const result = getSelectedCasesWithOptions(optionDef, {
type: 'checkbox',
caseNames: ['C', 'A'],
});
assert.deepEqual(
result.map((c) => c.name),
['A', 'C'],
);
});

test('checkbox:勾选了不带子项的 case 时返回空数组', () => {
const optionDef = checkbox([{ name: '纯开关项' }, { name: '带子项', option: ['x'] }]);
const result = getSelectedCasesWithOptions(optionDef, {
type: 'checkbox',
caseNames: ['纯开关项'],
});
assert.deepEqual(result, []);
});

test('checkbox:value 缺失时回退到 default_case', () => {
const optionDef = checkbox(
[
{ name: 'A', option: ['a1'] },
{ name: 'B', option: ['b1'] },
],
['B'],
);
const result = getSelectedCasesWithOptions(optionDef, undefined);
assert.deepEqual(
result.map((c) => c.name),
['B'],
);
});

test('checkbox:既无 value 也无 default_case 时返回空数组,不抛错', () => {
const optionDef = checkbox([{ name: 'A', option: ['a1'] }]);
assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []);
});

test('switch:按布尔值取对应分支,两个分支都可挂子项', () => {
const optionDef = {
type: 'switch' as const,
cases: [
{ name: 'Yes', option: ['on1'] },
{ name: 'No', option: ['off1'] },
] as [CaseItem, CaseItem],
};
const on = getSelectedCasesWithOptions(optionDef, { type: 'switch', value: true });
const off = getSelectedCasesWithOptions(optionDef, { type: 'switch', value: false });
assert.deepEqual(
on.map((c) => c.name),
['Yes'],
);
assert.deepEqual(
off.map((c) => c.name),
['No'],
);
});

test('switch:value 缺失时按未选中处理', () => {
const optionDef = {
type: 'switch' as const,
cases: [{ name: 'Yes', option: ['on1'] }, { name: 'No' }] as [CaseItem, CaseItem],
};
assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []);
});

test('select:按 caseName 取对应 case', () => {
const optionDef = {
type: 'select' as const,
cases: [{ name: '简单' }, { name: '完整', option: ['子项1', '子项2'] }],
};
const result = getSelectedCasesWithOptions(optionDef, { type: 'select', caseName: '完整' });
assert.deepEqual(
result.map((c) => c.name),
['完整'],
);
});

test('select:无 value 时依次回退 default_case、首个 case', () => {
const withDefault = {
type: 'select' as const,
cases: [{ name: 'A' }, { name: 'B', option: ['b1'] }],
default_case: 'B',
};
assert.deepEqual(
getSelectedCasesWithOptions(withDefault, undefined).map((c) => c.name),
['B'],
);

const withoutDefault = { type: 'select' as const, cases: [{ name: 'A', option: ['a1'] }] };
assert.deepEqual(
getSelectedCasesWithOptions(withoutDefault, undefined).map((c) => c.name),
['A'],
);
});

test('select:省略 type 时按 select 处理(协议允许)', () => {
const optionDef = { cases: [{ name: 'A', option: ['a1'] }] };
assert.deepEqual(
getSelectedCasesWithOptions(optionDef, undefined).map((c) => c.name),
['A'],
);
});

test('input / hotkey:没有 case,返回空数组', () => {
const optionDef = {
type: 'input' as const,
inputs: [{ name: 'field' }],
};
assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []);
});

test('回归:checkbox 不得按单选逻辑只返回第一个分支', () => {
// 真实场景:战斗检测目标舰种 12 个 case,每个挂 3 个子项
const cases: CaseItem[] = [];
for (let node = 1; node <= 2; node += 1) {
for (let ship = 1; ship <= 2; ship += 1) {
cases.push({
name: `节点${node}舰种${ship}`,
option: [`节点${node}检测目标名`, `节点${node}反向检测目标`],
});
}
}
const optionDef = checkbox(cases);
const result = getSelectedCasesWithOptions(optionDef, {
type: 'checkbox',
caseNames: ['节点2舰种1', '节点1舰种2'],
});
assert.equal(result.length, 2, '两个勾选项都应被推导出来');
assert.deepEqual(
result.flatMap((c) => c.option ?? []),
['节点1检测目标名', '节点1反向检测目标', '节点2检测目标名', '节点2反向检测目标'],
);
});

test('findSwitchCase:仍按 Yes/No 别名匹配', () => {
const cases: CaseItem[] = [{ name: 'Yes' }, { name: 'No' }];
assert.equal(findSwitchCase(cases, true)?.name, 'Yes');
assert.equal(findSwitchCase(cases, false)?.name, 'No');
assert.equal(findSwitchCase(undefined, true), undefined);
});
Loading