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);
+}