diff --git a/src/components/AddTaskPanel.tsx b/src/components/AddTaskPanel.tsx index 370a7b95..a3d2e352 100644 --- a/src/components/AddTaskPanel.tsx +++ b/src/components/AddTaskPanel.tsx @@ -23,14 +23,24 @@ import { addTaskPanelResizeStep, } from '@/types/config'; import { Tooltip } from './ui/Tooltip'; -import type { TaskItem, ActionConfig, GroupItem } from '@/types/interface'; +import type { + TaskItem, + ActionConfig, + GroupItem, + PreTaskConfig, + OptionDefinition, + OptionValue, + CaseItem, +} from '@/types/interface'; +import { normalizePreTaskConfigs } from '@/types/interface'; import type { MxuSpecialTaskDefinition } from '@/types/specialTasks'; import { getAllMxuSpecialTasks, MXU_LAUNCH_TASK_NAME, MXU_KILLPROC_TASK_NAME, } from '@/types/specialTasks'; -import { generateId } from '@/stores/helpers'; +import { generateId, initializeAllOptionValues } from '@/stores/helpers'; +import { findSwitchCase } from '@/utils/optionHelpers'; import { getProcessNameFromPath } from '@/utils/paths'; import clsx from 'clsx'; @@ -160,6 +170,145 @@ function createDefaultAction(defaultProgram?: string): ActionConfig { }; } +/** + * 以兼容 shell_words crate(POSIX 解析)的方式对单个参数做引号转义。 + * 字母/数字/常用安全符号原样输出,其他字符使用单引号包裹。 + */ +function shellQuote(arg: string): string { + if (arg === '') return "''"; + if (/^[a-zA-Z0-9._/\-+=:@%]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +/** 将参数数组以 shell 安全形式 join 为字符串,便于回写 ActionConfig.args */ +function joinArgsShell(args: string[] | undefined): string { + if (!args || args.length === 0) return ''; + return args.map(shellQuote).join(' '); +} + +/** 将 OptionValue 转换为协议要求的「原始 JSON 取值」(select/switch -> case.name 字符串等) */ +function rawValueFromOptionValue(value: OptionValue, optionDef: OptionDefinition): unknown { + if (value.type === 'select') return value.caseName; + if (value.type === 'checkbox') return value.caseNames; + if (value.type === 'input') return value.values; + if (value.type === 'switch') { + const cases = (optionDef as { cases?: CaseItem[] }).cases; + const matched = findSwitchCase(cases, value.value); + return matched?.name ?? (value.value ? 'Yes' : 'No'); + } + return null; +} + +/** + * 按 PI v2.7.0 规范,构造 pretask option 的「单行紧凑 JSON」字符串。 + * 仅快照默认值,不在此处做控制器/资源过滤(pretask 顶层即生效)。 + * 若无 option 或 allOptions 为空,则返回 null。 + */ +function buildPretaskOptionJson( + optionKeys: string[] | undefined, + allOptions: Record | undefined, +): string | null { + if (!optionKeys || optionKeys.length === 0 || !allOptions) return null; + const valuesMap = initializeAllOptionValues(optionKeys, allOptions); + const result: Record = {}; + for (const [key, value] of Object.entries(valuesMap)) { + const def = allOptions[key]; + if (!def) continue; + result[key] = rawValueFromOptionValue(value, def); + } + return JSON.stringify(result); +} + +/** 以 pretask 配置生成新的 ActionConfig,args 末尾按需追加 option 取值 JSON */ +function createActionFromPretask( + pretask: PreTaskConfig, + allOptions: Record | undefined, + resolvedLabel?: string, +): ActionConfig { + const args = [...(pretask.args ?? [])]; + const optionJson = buildPretaskOptionJson(pretask.option, allOptions); + if (optionJson !== null) { + args.push(optionJson); + } + return { + id: generateId(), + enabled: true, + program: pretask.exec, + args: joinArgsShell(args), + // PI 协议要求 Client 等待每个 pretask 结束并在失败时中止启动;这里以 ActionConfig 形式承载, + // 通过保留 waitForExit=true 来贴近协议语义(用户仍可在 UI 中调整)。 + waitForExit: true, + skipIfRunning: false, + useCmd: false, + customName: resolvedLabel, + }; +} + +/** pretask 按钮:与 TaskButton 风格保持一致,但不依赖 controller/resource 兼容性 */ +function PreTaskButton({ + pretask, + count, + label, + langKey, + basePath, + onClick, +}: { + pretask: PreTaskConfig; + count: number; + label: string; + langKey: string; + basePath: string; + onClick: () => void; +}) { + const { t } = useTranslation(); + const { resolveI18nText, interfaceTranslations } = useAppStore(); + + const translations = interfaceTranslations[langKey]; + const resolvedDescription = useResolvedContent( + pretask.description ? resolveI18nText(pretask.description, langKey) : undefined, + basePath, + translations, + ); + + const hasDescription = !!resolvedDescription.html || resolvedDescription.loading; + + const tooltipContent = hasDescription ? ( +
+ {resolvedDescription.loading ? ( +
+ + {t('taskItem.loadingDescription')} +
+ ) : resolvedDescription.html ? ( +
+ ) : null} +
+ ) : null; + + return ( + + + + ); +} + export function AddTaskPanel() { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); @@ -402,6 +551,56 @@ export function AddTaskPanel() { }); const [ungroupedExpanded, setUngroupedExpanded] = useState(true); const [specialExpanded, setSpecialExpanded] = useState(true); + const [pretaskExpanded, setPretaskExpanded] = useState(true); + + // v2.7.0: 收集合并后的 pretask 列表(loader 已合并主文件 + import) + const allPretasks = useMemo( + () => normalizePreTaskConfigs(projectInterface?.pretask), + [projectInterface?.pretask], + ); + + // 按搜索关键词过滤 pretask(与 task 一致:匹配 name 或解析后的 label) + const filteredPretasks = useMemo(() => { + if (allPretasks.length === 0) return []; + const searchLower = searchQuery.toLowerCase(); + return allPretasks.filter((p) => { + const label = resolveI18nText(p.label, langKey) || p.name || p.exec; + return ( + (p.name?.toLowerCase().includes(searchLower) ?? false) || + p.exec.toLowerCase().includes(searchLower) || + label.toLowerCase().includes(searchLower) + ); + }); + }, [allPretasks, searchQuery, resolveI18nText, langKey]); + + // 统计每个 pretask 已被加入 preActions 的次数(按 program 路径匹配) + const pretaskCounts = useMemo(() => { + const counts: Record = {}; + const preActions = instance?.preActions ?? []; + for (const p of allPretasks) { + counts[p.exec] = preActions.filter((a) => a.program === p.exec).length; + } + return counts; + }, [allPretasks, instance?.preActions]); + + /** 点击 pretask 按钮:基于 pretask 配置生成 ActionConfig,加入 preActions */ + const handleAddPretask = useCallback( + (pretask: PreTaskConfig) => { + if (!instance) return; + const resolvedLabel = resolveI18nText(pretask.label, langKey) || pretask.name || undefined; + const action = createActionFromPretask(pretask, projectInterface?.option, resolvedLabel); + addPreAction(instance.id, action); + setShowAddTaskPanel(false); + }, + [ + instance, + projectInterface?.option, + resolveI18nText, + langKey, + addPreAction, + setShowAddTaskPanel, + ], + ); // 当分组定义变化时,移除已失效 key,并为新分组注入 default_expand 默认值 useEffect(() => { @@ -521,6 +720,45 @@ export function AddTaskPanel() { ); }; + /** 渲染 pretask 分组(与普通 task 分组风格一致) */ + const renderPretaskSection = () => { + if (filteredPretasks.length === 0) return null; + const contentId = 'add-task-panel-section-pretask'; + return ( +
+ {renderSectionHeader( + t('addTaskPanel.pretasks'), + pretaskExpanded, + () => setPretaskExpanded((prev) => !prev), + filteredPretasks.length, + contentId, + )} + {pretaskExpanded && ( +
+
+ {filteredPretasks.map((pretask) => { + const label = + resolveI18nText(pretask.label, langKey) || pretask.name || pretask.exec; + const key = pretask.name || pretask.exec; + return ( + handleAddPretask(pretask)} + /> + ); + })} +
+
+ )} +
+ ); + }; + /** 渲染分组区块(带可折叠标题) */ const renderGroupSection = (group: GroupItem, tasks: TaskItem[]) => { if (tasks.length === 0) return null; @@ -724,6 +962,9 @@ export function AddTaskPanel() { filteredTasks.length > 0 && renderTaskGrid(filteredTasks) )} + {/* 前置任务(pretask):放在普通任务与特殊任务之间,仅当存在配置且有活动实例时渲染 */} + {instance && renderPretaskSection()} + {instance && (
{renderSectionHeader( diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index bc9fb9f2..ad41c4e2 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -488,6 +488,7 @@ export default { specialTasks: 'Special Tasks', allSpecialTasksAdded: 'All added', ungroupedTasks: 'Others', + pretasks: 'Pre-tasks', resizeHandleAriaLabel: 'Resize add task panel height', }, diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 7c9c2613..8c0f3c93 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -483,6 +483,7 @@ export default { allSpecialTasksAdded: 'すべて追加済み', collapse: 'パネルを閉じる', ungroupedTasks: 'その他', + pretasks: '事前タスク', resizeHandleAriaLabel: 'タスク追加パネルの高さを調整', }, diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index e28bdff5..a48e4e00 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -480,6 +480,7 @@ export default { specialTasks: '특수 작업', allSpecialTasksAdded: '모두 추가됨', ungroupedTasks: '기타', + pretasks: '사전 작업', resizeHandleAriaLabel: '작업 추가 패널 높이 조정', }, diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index ff911e47..61b53219 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -476,6 +476,7 @@ export default { allSpecialTasksAdded: '已全部添加', collapse: '收起面板', ungroupedTasks: '其他', + pretasks: '前置任务', resizeHandleAriaLabel: '调整添加任务面板高度', }, diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index e49303dc..ba31b774 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -472,6 +472,7 @@ export default { allSpecialTasksAdded: '已全部新增', collapse: '收起面板', ungroupedTasks: '其他', + pretasks: '前置任務', resizeHandleAriaLabel: '調整新增任務面板高度', }, diff --git a/src/services/interfaceLoader.ts b/src/services/interfaceLoader.ts index 01d19a80..8a4cd19b 100644 --- a/src/services/interfaceLoader.ts +++ b/src/services/interfaceLoader.ts @@ -6,7 +6,9 @@ import type { ControllerType, PresetItem, GroupItem, + PreTaskConfig, } from '@/types/interface'; +import { normalizePreTaskConfigs } from '@/types/interface'; import { loggers } from '@/utils/logger'; import { parseJsonc } from '@/utils/jsonc'; import { isTauri } from '@/utils/paths'; @@ -15,7 +17,7 @@ import { setBackendPort } from '@/utils/backendApi'; const log = loggers.app; /** - * 可导入的 PI 文件结构(支持 task、option、preset 和 group 字段) + * 可导入的 PI 文件结构(支持 task、option、preset、group 和 pretask 字段) */ interface ImportableInterface { task?: TaskItem[]; @@ -24,6 +26,8 @@ interface ImportableInterface { preset?: PresetItem[]; /** v2.4.0: 支持导入 group */ group?: GroupItem[]; + /** v2.7.1: 支持导入 pretask */ + pretask?: PreTaskConfig | PreTaskConfig[]; } export interface LoadResult { @@ -233,6 +237,14 @@ function mergeImported(pi: ProjectInterface, imported: ImportableInterface): voi log.info(`合并了 ${imported.preset.length} 个导入的 preset`); } + // v2.7.1: 合并 pretask(主文件已在 normalizePretask 处理;此处把导入文件的条目按数组顺序追加) + const importedPretasks = normalizePreTaskConfigs(imported.pretask); + if (importedPretasks.length > 0) { + const existing = normalizePreTaskConfigs(pi.pretask); + pi.pretask = [...existing, ...importedPretasks]; + log.info(`合并了 ${importedPretasks.length} 个导入的 pretask`); + } + // v2.4.0: 合并 group 数组(按 name 去重,保持先定义优先) if (imported.group && imported.group.length > 0) { const existingGroups = pi.group || []; diff --git a/src/types/interface.ts b/src/types/interface.ts index f4d67a6a..84bbb8ef 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -28,6 +28,41 @@ export interface ProjectInterface { import?: string[]; /** v2.3.0: 预设配置 */ preset?: PresetItem[]; + /** v2.7.0: Controller 启动前执行的预任务配置,可为单对象或对象数组 */ + pretask?: PreTaskConfig | PreTaskConfig[]; +} + +/** v2.7.0: 预任务配置 */ +export interface PreTaskConfig { + /** 必须。要执行的程序路径,可以是系统 PATH 中的可执行文件 */ + exec: string; + /** 可选。固定参数数组,按顺序传递给 exec */ + args?: string[]; + /** v2.7.2: 可选。预任务唯一标识,用于 Client UI/日志区分;缺省时 Client 可回退到 exec */ + name?: string; + /** v2.7.2: 可选。预任务显示名称,支持 i18n */ + label?: string; + /** v2.7.2: 可选。预任务详细描述,支持 i18n / 文件路径 / URL,内容支持 Markdown */ + description?: string; + /** v2.7.2: 可选。预任务图标路径,相对于 interface.json 所在目录,支持 i18n */ + icon?: string; + /** + * 可选。需要让用户选择的 option 键名列表,元素应与外层 option 配置中的键名对应。 + * 若设置,Client 应将这些 option 的当前取值序列化为单行紧凑 JSON 字符串并追加为最后一个参数。 + * 该字段仅用于生成传给预任务进程的参数,不参与 pipeline_override 合并。 + */ + option?: string[]; +} + +/** + * 将 PI 协议中的 pretask 字段(单对象或数组)标准化为数组。 + * 如果 pretask 未定义则返回空数组。 + */ +export function normalizePreTaskConfigs( + pretask: PreTaskConfig | PreTaskConfig[] | undefined, +): PreTaskConfig[] { + if (!pretask) return []; + return Array.isArray(pretask) ? pretask : [pretask]; } /** v2.4.0: 任务分组声明 */