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
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
184 changes: 131 additions & 53 deletions src/components/TaskItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -304,6 +310,7 @@ export function TaskItem({ instanceId, task }: TaskItemProps) {
const {
projectInterface,
toggleTaskEnabled,
setTaskRunOnce,
toggleTaskExpanded,
removeTaskFromInstance,
confirmBeforeDelete,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
},
Expand All @@ -708,6 +776,9 @@ export function TaskItem({ instanceId, task }: TaskItemProps) {
confirmBeforeDelete,
showMenu,
isInstanceRunning,
isIncompatible,
handleRunFromHere,
handleRunSingle,
],
);

Expand Down Expand Up @@ -806,27 +877,30 @@ export function TaskItem({ instanceId, task }: TaskItemProps) {
</div>

{/* 启用复选框 - 运行时或不兼容时禁用 */}
<label
<div
className={clsx(
'flex items-center relative',
isInstanceRunning || isIncompatible
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
isInstanceRunning || isIncompatible ? 'opacity-50' : '',
)}
title={isIncompatible ? incompatibleReason : undefined}
title={
isIncompatible
? incompatibleReason
: checkboxState === 'once'
? t('taskItem.runOnceHint')
: undefined
}
>
<input
type="checkbox"
checked={task.enabled}
onChange={() => !isIncompatible && toggleTaskEnabled(instanceId, task.id)}
<TriStateCheckbox
state={checkboxState}
disabled={isInstanceRunning || isIncompatible}
className="w-4 h-4 rounded border-border-strong accent-accent disabled:cursor-not-allowed"
onClick={handleCheckboxClick}
onContextMenu={handleCheckboxContextMenu}
/>
{/* 不兼容警告图标 */}
{isIncompatible && (
<AlertCircle className="w-3.5 h-3.5 text-warning absolute -top-1 -right-1" />
<AlertCircle className="w-3.5 h-3.5 text-warning absolute -top-1 -right-1 pointer-events-none" />
)}
</label>
</div>

{/* 任务名称 + 展开区域容器 */}
<div className="flex-1 flex items-center min-w-0">
Expand All @@ -852,7 +926,11 @@ export function TaskItem({ instanceId, task }: TaskItemProps) {
<span
className={clsx(
'min-w-0 text-sm font-medium truncate',
task.enabled ? 'text-text-primary' : 'text-text-muted',
task.enabled
? 'text-text-primary'
: task.runOnce
? 'text-accent'
: 'text-text-muted',
)}
>
{displayName}
Expand Down
2 changes: 1 addition & 1 deletion src/components/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
32 changes: 15 additions & 17 deletions src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> => {
const { schedulePolicyName, onPhaseChange } = options || {};
async (targetInstance: Instance, options?: TaskStartOptions): Promise<boolean> => {
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;
}

Expand All @@ -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 })}`,
Expand Down Expand Up @@ -1168,6 +1161,11 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr
const scheduleTriggerRef = useRef<typeof startTasksForInstance>(startTasksForInstance);
scheduleTriggerRef.current = startTasksForInstance;

useEffect(() => {
taskStartService.setHandler((instance, options) => startTasksForInstance(instance, options));
return () => taskStartService.setHandler(null);
}, [startTasksForInstance]);

const addLogRef = useRef(addLog);
addLogRef.current = addLog;

Expand Down
Loading
Loading