From fdbc4201c9f606b5ce6be87d340cc3b5855d1d05 Mon Sep 17 00:00:00 2001 From: overflow65537 Date: Sun, 31 May 2026 15:59:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=8E=E6=AD=A4?= =?UTF-8?q?=E5=A4=84=E8=BF=90=E8=A1=8C=E3=80=81=E5=8D=95=E7=8B=AC=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=8E=E5=8D=95=E6=AC=A1=E8=BF=90=E8=A1=8C=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增任务右键菜单与三态复选框,允许从指定任务启动、仅运行单个任务,以及通过右键设置单次运行标记;左键复选框仅在启用与禁用间切换。 Co-authored-by: Cursor --- src/App.tsx | 5 +- src/components/TaskItem.tsx | 184 ++++++++++++++++++------- src/components/TaskList.tsx | 2 +- src/components/Toolbar.tsx | 32 ++--- src/components/ui/TriStateCheckbox.tsx | 52 +++++++ src/i18n/locales/en-US.ts | 5 + src/i18n/locales/ja-JP.ts | 5 + src/i18n/locales/ko-KR.ts | 5 + src/i18n/locales/zh-CN.ts | 5 + src/i18n/locales/zh-TW.ts | 5 + src/services/taskStartService.ts | 26 ++++ src/stores/appStore.ts | 45 +++++- src/stores/types.ts | 2 + src/types/interface.ts | 2 + src/utils/taskRunFilter.ts | 41 ++++++ 15 files changed, 340 insertions(+), 76 deletions(-) create mode 100644 src/components/ui/TriStateCheckbox.tsx create mode 100644 src/services/taskStartService.ts create mode 100644 src/utils/taskRunFilter.ts diff --git a/src/App.tsx b/src/App.tsx index cea49cb9..5845d3e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1184,8 +1184,11 @@ function App() { kind === 'task-progress' || kind === 'tasks-completed'; - const handleStateChanged = (_instanceId: string, kind: string) => { + const handleStateChanged = (instanceId: string, kind: string) => { if (isTaskKind(kind)) pendingTaskKind = true; + if (kind === 'tasks-completed') { + useAppStore.getState().clearAllTaskRunOnce(instanceId); + } if (debounceTimer) clearTimeout(debounceTimer); const shouldSyncRunning = pendingTaskKind; debounceTimer = setTimeout(async () => { diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index 85016dec..b656fc14 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { GripVertical, ChevronRight, X, Loader2, FileText, Link, AlertCircle } from 'lucide-react'; +import { GripVertical, ChevronRight, X, Loader2, FileText, Link, AlertCircle, Play, CircleDot } from 'lucide-react'; import { useAppStore, type TaskRunStatus } from '@/stores/appStore'; import { maaService } from '@/services/maaService'; import { useResolvedContent } from '@/services/contentResolver'; @@ -12,6 +12,12 @@ import { ContextMenu, useContextMenu } from './ContextMenu'; import { Tooltip } from './ui/Tooltip'; import { ConfirmDialog } from './ConfirmDialog'; import { buildListItemMenuItems, InlineNameEditor } from './listItemShared'; +import { + TriStateCheckbox, + getTaskCheckboxState, +} from './ui/TriStateCheckbox'; +import { taskStartService } from '@/services/taskStartService'; +import type { MenuItem } from './ContextMenu'; import type { SelectedTask } from '@/types/interface'; import { isMxuSpecialTask, getMxuSpecialTask, findMxuOptionByKey } from '@/types/specialTasks'; import { getInterfaceLangKey } from '@/i18n'; @@ -304,6 +310,7 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { const { projectInterface, toggleTaskEnabled, + setTaskRunOnce, toggleTaskExpanded, removeTaskFromInstance, confirmBeforeDelete, @@ -391,8 +398,8 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { t, ]); - // 紧凑模式:实例运行时,未启用的任务显示为紧凑样式 - const isCompact = isInstanceRunning && !task.enabled; + // 紧凑模式:实例运行时,未参与运行的任务显示为紧凑样式 + const isCompact = isInstanceRunning && !task.enabled && !task.runOnce && taskRunStatus === 'idle'; // 判断是否可以编辑选项:实例未运行时始终可以编辑,运行中只有 pending 或 idle 状态的任务可以编辑 const canEditOptions = @@ -621,6 +628,50 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { t, ]); + const checkboxState = getTaskCheckboxState(task.enabled, Boolean(task.runOnce)); + + const handleCheckboxClick = () => { + if (isInstanceRunning || isIncompatible) return; + toggleTaskEnabled(instanceId, task.id); + }; + + const handleCheckboxContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (isInstanceRunning || isIncompatible) return; + + const menuItems: MenuItem[] = [ + { + id: 'run-once', + label: t('contextMenu.runOnceTask'), + icon: CircleDot, + checked: Boolean(task.runOnce), + onClick: () => setTaskRunOnce(instanceId, task.id, !task.runOnce), + }, + { + id: 'clear-run-once', + label: t('contextMenu.clearRunOnceTask'), + disabled: !task.runOnce, + onClick: () => setTaskRunOnce(instanceId, task.id, false), + }, + ]; + + showMenu(e, menuItems); + }, + [t, task.runOnce, instanceId, task.id, isInstanceRunning, isIncompatible, setTaskRunOnce, showMenu], + ); + + const handleRunFromHere = useCallback(async () => { + if (!instance || isInstanceRunning || isIncompatible) return; + await taskStartService.start(instance, { startFromTaskId: task.id }); + }, [instance, isInstanceRunning, isIncompatible, task.id]); + + const handleRunSingle = useCallback(async () => { + if (!instance || isInstanceRunning || isIncompatible) return; + await taskStartService.start(instance, { singleTaskId: task.id }); + }, [instance, isInstanceRunning, isIncompatible, task.id]); + const handleNameClick = (e: React.MouseEvent) => { e.stopPropagation(); if (isInstanceRunning || isIncompatible) return; @@ -649,45 +700,62 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { const tasks = instance.selectedTasks; const taskIndex = tasks.findIndex((t) => t.id === task.id); - const menuItems = buildListItemMenuItems({ - labels: { - duplicate: t('contextMenu.duplicateTask'), - rename: t('contextMenu.renameTask'), - enable: t('contextMenu.enableTask'), - disable: t('contextMenu.disableTask'), - expand: t('contextMenu.expandOptions'), - collapse: t('contextMenu.collapseOptions'), - moveUp: t('contextMenu.moveUp'), - moveDown: t('contextMenu.moveDown'), - moveToTop: t('contextMenu.moveToTop'), - moveToBottom: t('contextMenu.moveToBottom'), - delete: t('contextMenu.deleteTask'), + const menuItems: MenuItem[] = [ + { + id: 'run-from-here', + label: t('contextMenu.runFromHere'), + icon: Play, + disabled: isInstanceRunning || isIncompatible, + onClick: () => void handleRunFromHere(), }, - isEnabled: task.enabled, - isExpanded: !!task.expanded, - canExpand, - isFirst: taskIndex === 0, - isLast: taskIndex === tasks.length - 1, - isLocked: isInstanceRunning, - onDuplicate: () => duplicateTask(instanceId, task.id), - onRename: () => { - setEditName(task.customName || ''); - setIsEditing(true); + { + id: 'run-single', + label: t('contextMenu.runSingleTask'), + icon: Play, + disabled: isInstanceRunning || isIncompatible, + onClick: () => void handleRunSingle(), }, - onToggle: () => toggleTaskEnabled(instanceId, task.id), - onExpand: () => toggleTaskExpanded(instanceId, task.id), - onMoveUp: () => moveTaskUp(instanceId, task.id), - onMoveDown: () => moveTaskDown(instanceId, task.id), - onMoveToTop: () => moveTaskToTop(instanceId, task.id), - onMoveToBottom: () => moveTaskToBottom(instanceId, task.id), - onDelete: () => { - if (!confirmBeforeDelete) { - removeTaskFromInstance(instanceId, task.id); - return; - } - setShowDeleteConfirm(true); - }, - }); + { id: 'divider-run', label: '', divider: true }, + ...buildListItemMenuItems({ + labels: { + duplicate: t('contextMenu.duplicateTask'), + rename: t('contextMenu.renameTask'), + enable: t('contextMenu.enableTask'), + disable: t('contextMenu.disableTask'), + expand: t('contextMenu.expandOptions'), + collapse: t('contextMenu.collapseOptions'), + moveUp: t('contextMenu.moveUp'), + moveDown: t('contextMenu.moveDown'), + moveToTop: t('contextMenu.moveToTop'), + moveToBottom: t('contextMenu.moveToBottom'), + delete: t('contextMenu.deleteTask'), + }, + isEnabled: task.enabled, + isExpanded: !!task.expanded, + canExpand, + isFirst: taskIndex === 0, + isLast: taskIndex === tasks.length - 1, + isLocked: isInstanceRunning, + onDuplicate: () => duplicateTask(instanceId, task.id), + onRename: () => { + setEditName(task.customName || ''); + setIsEditing(true); + }, + onToggle: () => toggleTaskEnabled(instanceId, task.id), + onExpand: () => toggleTaskExpanded(instanceId, task.id), + onMoveUp: () => moveTaskUp(instanceId, task.id), + onMoveDown: () => moveTaskDown(instanceId, task.id), + onMoveToTop: () => moveTaskToTop(instanceId, task.id), + onMoveToBottom: () => moveTaskToBottom(instanceId, task.id), + onDelete: () => { + if (!confirmBeforeDelete) { + removeTaskFromInstance(instanceId, task.id); + return; + } + setShowDeleteConfirm(true); + }, + }), + ]; showMenu(e, menuItems); }, @@ -708,6 +776,9 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { confirmBeforeDelete, showMenu, isInstanceRunning, + isIncompatible, + handleRunFromHere, + handleRunSingle, ], ); @@ -806,27 +877,30 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { {/* 启用复选框 - 运行时或不兼容时禁用 */} - + {/* 任务名称 + 展开区域容器 */}
@@ -852,7 +926,11 @@ export function TaskItem({ instanceId, task }: TaskItemProps) { {displayName} diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx index 648f8cfc..a358e3d8 100644 --- a/src/components/TaskList.tsx +++ b/src/components/TaskList.tsx @@ -283,7 +283,7 @@ export function TaskList() { if (!instance) return; const tasks = instance.selectedTasks; - const hasEnabledTasks = tasks.some((t) => t.enabled); + const hasEnabledTasks = tasks.some((t) => t.enabled || t.runOnce); const hasExpandedTasks = tasks.some((t) => t.expanded); const hasTasks = tasks.length > 0; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 59fb2229..a588cf63 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -31,6 +31,8 @@ import { } from '@/components/connection/callbackCache'; import { scheduleService } from '@/services/scheduleService'; import { stopInstanceTasks } from '@/services/taskStopService'; +import { taskStartService, type TaskStartOptions } from '@/services/taskStartService'; +import { isTaskSelectedForRun, filterTasksForRun } from '@/utils/taskRunFilter'; import { isTauri } from '@/utils/paths'; import { onStateChanged } from '@/services/wsService'; import { buildPiEnvVars } from '@/utils/piEnv'; @@ -140,7 +142,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr }, [tasks, projectInterface, currentControllerName, currentResourceName]); // 只要有启用的任务就可以运行(连接和资源加载会在 startTasksForInstance 中自动处理) - const canRun = tasks.some((t) => t.enabled); + const canRun = tasks.some(isTaskSelectedForRun); const handleSelectAll = () => { if (!instance) return; @@ -218,24 +220,15 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr * @returns 是否成功启动 */ const startTasksForInstance = useCallback( - async ( - targetInstance: Instance, - options?: { - /** 定时策略名称(定时执行时传入) */ - schedulePolicyName?: string; - /** 自动连接阶段变化回调(用于 UI 状态更新) */ - onPhaseChange?: (phase: AutoConnectPhase) => void; - }, - ): Promise => { - const { schedulePolicyName, onPhaseChange } = options || {}; + async (targetInstance: Instance, options?: TaskStartOptions): Promise => { + const { schedulePolicyName, onPhaseChange, startFromTaskId, singleTaskId } = options || {}; const targetId = targetInstance.id; const targetTasks = targetInstance.selectedTasks || []; lastStartCancelledRef.current = false; - // 检查是否有启用的任务 - const enabledTasks = targetTasks.filter((t) => t.enabled); - if (enabledTasks.length === 0) { - log.warn(`实例 ${targetInstance.name} 没有启用的任务`); + const tasksToRun = filterTasksForRun(targetTasks, { startFromTaskId, singleTaskId }); + if (tasksToRun.length === 0) { + log.warn(`实例 ${targetInstance.name} 没有可运行的任务`); return false; } @@ -250,14 +243,14 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr const resourceName = selectedResource[targetId] || projectInterface?.resource[0]?.name; // 过滤掉不兼容当前控制器/资源的任务 - const compatibleTasks = enabledTasks.filter((t) => { + const compatibleTasks = tasksToRun.filter((t) => { const taskDef = projectInterface?.task.find((td) => td.name === t.taskName); return isTaskCompatible(taskDef, controllerName, resourceName); }); // 如果有任务因不兼容被跳过,记录警告 const compatibleTaskIds = new Set(compatibleTasks.map((t) => t.id)); - const skippedTasks = enabledTasks.filter((t) => !compatibleTaskIds.has(t.id)); + const skippedTasks = tasksToRun.filter((t) => !compatibleTaskIds.has(t.id)); if (skippedTasks.length > 0) { log.warn( `实例 ${targetInstance.name}: ${t('taskList.tasksSkippedDueToIncompatibility', { count: skippedTasks.length })}`, @@ -1168,6 +1161,11 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr const scheduleTriggerRef = useRef(startTasksForInstance); scheduleTriggerRef.current = startTasksForInstance; + useEffect(() => { + taskStartService.setHandler((instance, options) => startTasksForInstance(instance, options)); + return () => taskStartService.setHandler(null); + }, [startTasksForInstance]); + const addLogRef = useRef(addLog); addLogRef.current = addLog; diff --git a/src/components/ui/TriStateCheckbox.tsx b/src/components/ui/TriStateCheckbox.tsx new file mode 100644 index 00000000..83ff11bb --- /dev/null +++ b/src/components/ui/TriStateCheckbox.tsx @@ -0,0 +1,52 @@ +import type { MouseEvent } from 'react'; +import { Check, CircleDot } from 'lucide-react'; +import clsx from 'clsx'; + +export type TaskCheckboxState = 'off' | 'on' | 'once'; + +export function getTaskCheckboxState(enabled: boolean, runOnce: boolean): TaskCheckboxState { + if (runOnce) return 'once'; + if (enabled) return 'on'; + return 'off'; +} + +interface TriStateCheckboxProps { + state: TaskCheckboxState; + disabled?: boolean; + title?: string; + onClick: () => void; + onContextMenu?: (e: MouseEvent) => void; +} + +export function TriStateCheckbox({ + state, + disabled, + title, + onClick, + onContextMenu, +}: TriStateCheckboxProps) { + return ( + + ); +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index d4f88073..3525e729 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -249,6 +249,7 @@ export default { removeConfirmMessage: 'Are you sure you want to delete this task?', rename: 'Rename', clickToToggle: 'Click to toggle', + runOnceHint: 'Run once: included in the next start only', renameTask: 'Rename Task', customName: 'Custom Name', originalName: 'Original Name', @@ -785,6 +786,10 @@ export default { deselectAll: 'Deselect All', expandAllTasks: 'Expand All', collapseAllTasks: 'Collapse All', + runFromHere: 'Run From Here', + runSingleTask: 'Run This Task Only', + runOnceTask: 'Run Once', + clearRunOnceTask: 'Clear Run Once', // Screenshot panel context menu reconnect: 'Reconnect', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 8cd76746..942be279 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -243,6 +243,7 @@ export default { removeConfirmMessage: 'このタスクを削除してもよろしいですか?', rename: '名前を変更', clickToToggle: 'クリックで切替', + runOnceHint: '単発実行:次回起動時に1回だけ実行', renameTask: 'タスク名を変更', customName: 'カスタム名', originalName: '元の名前', @@ -784,6 +785,10 @@ export default { deselectAll: 'すべて解除', expandAllTasks: 'すべて展開', collapseAllTasks: 'すべて折りたたむ', + runFromHere: 'ここから実行', + runSingleTask: 'このタスクのみ実行', + runOnceTask: '単発実行', + clearRunOnceTask: '単発実行を解除', // スクリーンショットパネルのコンテキストメニュー reconnect: '再接続', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index b021fcf5..b80476c0 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -241,6 +241,7 @@ export default { removeConfirmMessage: '이 작업을 삭제하시겠습니까?', rename: '이름 변경', clickToToggle: '클릭하여 전환', + runOnceHint: '1회 실행: 다음 시작 시 한 번만 실행', renameTask: '작업 이름 변경', customName: '사용자 지정 이름', originalName: '원래 이름', @@ -777,6 +778,10 @@ export default { deselectAll: '모두 선택 해제', expandAllTasks: '모두 펼치기', collapseAllTasks: '모두 접기', + runFromHere: '여기서 실행', + runSingleTask: '이 작업만 실행', + runOnceTask: '1회 실행', + clearRunOnceTask: '1회 실행 취소', // 스크린샷 패널 컨텍스트 메뉴 reconnect: '다시 연결', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0359f807..1b9f95ea 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -241,6 +241,7 @@ export default { removeConfirmMessage: '确定要删除这个任务吗?', rename: '重命名', clickToToggle: '单击选中/取消', + runOnceHint: '单次运行:下次启动时执行一次', renameTask: '重命名任务', customName: '自定义名称', originalName: '原始名称', @@ -779,6 +780,10 @@ export default { deselectAll: '取消全选', expandAllTasks: '展开全部', collapseAllTasks: '折叠全部', + runFromHere: '从此处运行', + runSingleTask: '单独运行', + runOnceTask: '单次运行', + clearRunOnceTask: '取消单次运行', // 截图面板右键菜单 reconnect: '重新连接', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index f954a9ab..649516a4 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -237,6 +237,7 @@ export default { removeConfirmMessage: '確定要刪除這個任務嗎?', rename: '重新命名', clickToToggle: '單擊選中/取消', + runOnceHint: '單次執行:下次啟動時執行一次', renameTask: '重新命名任務', customName: '自訂名稱', originalName: '原始名稱', @@ -764,6 +765,10 @@ export default { deselectAll: '取消全選', expandAllTasks: '展開全部', collapseAllTasks: '摺疊全部', + runFromHere: '從此處執行', + runSingleTask: '單獨執行', + runOnceTask: '單次執行', + clearRunOnceTask: '取消單次執行', // 截圖面板右鍵選單 reconnect: '重新連接', diff --git a/src/services/taskStartService.ts b/src/services/taskStartService.ts new file mode 100644 index 00000000..68a6a8f6 --- /dev/null +++ b/src/services/taskStartService.ts @@ -0,0 +1,26 @@ +import type { Instance } from '@/types/interface'; +import type { TaskRunFilterOptions } from '@/utils/taskRunFilter'; + +export type AutoConnectPhase = 'idle' | 'searching' | 'connecting' | 'loading_resource'; + +export interface TaskStartOptions extends TaskRunFilterOptions { + /** 定时策略名称(定时执行时传入) */ + schedulePolicyName?: string; + /** 自动连接阶段变化回调(用于 UI 状态更新) */ + onPhaseChange?: (phase: AutoConnectPhase) => void; +} + +export type TaskStartHandler = (instance: Instance, options?: TaskStartOptions) => Promise; + +let handler: TaskStartHandler | null = null; + +export const taskStartService = { + setHandler(fn: TaskStartHandler | null) { + handler = fn; + }, + + async start(instance: Instance, options?: TaskStartOptions): Promise { + if (!handler) return false; + return handler(instance, options); + }, +}; diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index ff0b8f40..35b04d74 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -630,13 +630,49 @@ export const useAppStore = create()( })), toggleTaskEnabled: (instanceId, taskId) => + set((state) => ({ + instances: state.instances.map((i) => + i.id === instanceId + ? { + ...i, + selectedTasks: i.selectedTasks.map((t) => { + if (t.id !== taskId) return t; + if (t.runOnce) { + return { ...t, enabled: false, runOnce: false }; + } + return { ...t, enabled: !t.enabled, runOnce: false }; + }), + } + : i, + ), + })), + + setTaskRunOnce: (instanceId, taskId, runOnce) => + set((state) => ({ + instances: state.instances.map((i) => + i.id === instanceId + ? { + ...i, + selectedTasks: i.selectedTasks.map((t) => + t.id === taskId + ? runOnce + ? { ...t, enabled: false, runOnce: true } + : { ...t, runOnce: false } + : t, + ), + } + : i, + ), + })), + + clearAllTaskRunOnce: (instanceId) => set((state) => ({ instances: state.instances.map((i) => i.id === instanceId ? { ...i, selectedTasks: i.selectedTasks.map((t) => - t.id === taskId ? { ...t, enabled: !t.enabled } : t, + t.runOnce ? { ...t, runOnce: false } : t, ), } : i, @@ -722,13 +758,13 @@ export const useAppStore = create()( return { ...i, selectedTasks: i.selectedTasks.map((t) => { - if (!enabled) return { ...t, enabled: false }; + if (!enabled) return { ...t, enabled: false, runOnce: false }; // 全选时不兼容的任务显式禁用 const taskDef = state.projectInterface?.task.find((td) => td.name === t.taskName); if (!isTaskCompatible(taskDef, controllerName, resourceName)) { - return { ...t, enabled: false }; + return { ...t, enabled: false, runOnce: false }; } - return { ...t, enabled: true }; + return { ...t, enabled: true, runOnce: false }; }), }; }), @@ -790,6 +826,7 @@ export const useAppStore = create()( ...originalTask, id: generateId(), customName: newCustomName, + runOnce: false, optionValues: { ...originalTask.optionValues }, }; diff --git a/src/stores/types.ts b/src/stores/types.ts index 2448fc4a..e59fb989 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -177,6 +177,8 @@ export interface AppState { removeTaskFromInstance: (instanceId: string, taskId: string) => void; reorderTasks: (instanceId: string, oldIndex: number, newIndex: number) => void; toggleTaskEnabled: (instanceId: string, taskId: string) => void; + setTaskRunOnce: (instanceId: string, taskId: string, runOnce: boolean) => void; + clearAllTaskRunOnce: (instanceId: string) => void; toggleTaskExpanded: (instanceId: string, taskId: string) => void; setTaskOptionValue: ( instanceId: string, diff --git a/src/types/interface.ts b/src/types/interface.ts index f4d67a6a..26cd5e3f 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -214,6 +214,8 @@ export interface SelectedTask { taskName: string; customName?: string; // 用户自定义名称 enabled: boolean; + /** 单次运行:下次启动时包含该任务,运行结束后自动清除 */ + runOnce?: boolean; optionValues: Record; expanded: boolean; } diff --git a/src/utils/taskRunFilter.ts b/src/utils/taskRunFilter.ts new file mode 100644 index 00000000..a334e762 --- /dev/null +++ b/src/utils/taskRunFilter.ts @@ -0,0 +1,41 @@ +import type { SelectedTask } from '@/types/interface'; + +export interface TaskRunFilterOptions { + /** 从此任务开始运行(包含该任务,忽略之前的任务) */ + startFromTaskId?: string; + /** 仅运行指定任务 */ + singleTaskId?: string; +} + +/** 判断任务是否应在常规启动时被包含 */ +export function isTaskSelectedForRun(task: SelectedTask): boolean { + return task.enabled || Boolean(task.runOnce); +} + +/** 根据运行模式筛选待执行任务 */ +export function filterTasksForRun( + tasks: SelectedTask[], + options?: TaskRunFilterOptions, +): SelectedTask[] { + if (options?.singleTaskId) { + const task = tasks.find((t) => t.id === options.singleTaskId); + return task ? [task] : []; + } + + let startIndex = 0; + if (options?.startFromTaskId) { + const idx = tasks.findIndex((t) => t.id === options.startFromTaskId); + if (idx < 0) return []; + startIndex = idx; + } + + const sliced = tasks.slice(startIndex); + if (sliced.length === 0) return []; + + if (options?.startFromTaskId) { + const [anchor, ...rest] = sliced; + return [anchor, ...rest.filter(isTaskSelectedForRun)]; + } + + return sliced.filter(isTaskSelectedForRun); +}