feat: 任务列表支持勾选批量删除

- 每行任务左侧新增复选框; 表头「任务」列新增全选(仅作用于当前筛选结果)
- 勾选后列表头部出现「删除所选(N)」按钮, 二次确认后批量删除
- 批量删除保留「至少保留一个任务」保护; 勾选集合随列表变化自动裁剪已删除 id
- 单行删除行为不变
This commit is contained in:
yu 2026-11-02 15:18:50 +08:00
parent 1bb28e3368
commit 5795c39a6f
3 changed files with 87 additions and 2 deletions

View File

@ -175,6 +175,26 @@ export function App() {
} }
}; };
const deleteTasks = async (ids: string[]) => {
if (!ids.length) return;
if (ids.length >= flows.length) {
showToast("至少保留一个任务", "error");
return;
}
if (!window.confirm(`确定删除选中的 ${ids.length} 个任务吗?删除后不可恢复。`)) return;
try {
for (const id of ids) await window.rpa.flows.delete(id);
await refreshFlows();
if (activeFlowId && ids.includes(activeFlowId)) {
setActiveFlowId(null);
setPage("tasks");
}
showToast(`已删除 ${ids.length} 个任务`);
} catch (deleteError) {
showToast(deleteError instanceof Error ? deleteError.message : String(deleteError), "error");
}
};
const runTask = async (id: string) => { const runTask = async (id: string) => {
try { try {
const flow = await window.rpa.flows.get(id); const flow = await window.rpa.flows.get(id);
@ -260,6 +280,7 @@ export function App() {
onOpenEditor={(id) => void openEditor(id)} onOpenEditor={(id) => void openEditor(id)}
onRun={(id) => void runTask(id)} onRun={(id) => void runTask(id)}
onDelete={(id) => void deleteTask(id)} onDelete={(id) => void deleteTask(id)}
onDeleteMany={(ids) => void deleteTasks(ids)}
onRefresh={() => refreshFlows()} onRefresh={() => refreshFlows()}
onToast={showToast} onToast={showToast}
/> />

View File

@ -23,6 +23,7 @@ interface TasksPageProps {
onOpenEditor: (id: string) => void; onOpenEditor: (id: string) => void;
onRun: (id: string) => void; onRun: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onDeleteMany: (ids: string[]) => void;
onRefresh: () => void | Promise<void>; onRefresh: () => void | Promise<void>;
onToast: (message: string, kind?: "ok" | "error") => void; onToast: (message: string, kind?: "ok" | "error") => void;
} }
@ -46,9 +47,10 @@ function formatDuration(value?: number): string {
const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const; const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const;
export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onRefresh, onToast }: TasksPageProps) { export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onDeleteMany, onRefresh, onToast }: TasksPageProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null); const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = async () => {
@ -70,12 +72,30 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
if (!selectedId || !flows.some((flow) => flow.id === selectedId)) setSelectedId(flows[0].id); if (!selectedId || !flows.some((flow) => flow.id === selectedId)) setSelectedId(flows[0].id);
}, [flows, selectedId]); }, [flows, selectedId]);
// 勾选集合随任务列表变化裁剪,避免残留已删除任务的 id
useEffect(() => {
setCheckedIds((previous) => {
const alive = new Set(flows.map((flow) => flow.id));
const next = previous.filter((id) => alive.has(id));
return next.length === previous.length ? previous : next;
});
}, [flows]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase(); const keyword = query.trim().toLowerCase();
if (!keyword) return flows; if (!keyword) return flows;
return flows.filter((flow) => flow.name.toLowerCase().includes(keyword)); return flows.filter((flow) => flow.name.toLowerCase().includes(keyword));
}, [flows, query]); }, [flows, query]);
const toggleChecked = (id: string) => {
setCheckedIds((previous) => (previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id]));
};
const allFilteredChecked = filtered.length > 0 && filtered.every((flow) => checkedIds.includes(flow.id));
const toggleAllFiltered = () => {
setCheckedIds(allFilteredChecked ? [] : filtered.map((flow) => flow.id));
};
const selected = flows.find((flow) => flow.id === selectedId) || filtered[0] || null; const selected = flows.find((flow) => flow.id === selectedId) || filtered[0] || null;
const enabledCount = flows.filter((flow) => flow.enabled !== false).length; const enabledCount = flows.filter((flow) => flow.enabled !== false).length;
const failedCount = flows.filter((flow) => flow.lastRunStatus === "failed").length; const failedCount = flows.filter((flow) => flow.lastRunStatus === "failed").length;
@ -112,6 +132,11 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
<strong>任务列表</strong> <strong>任务列表</strong>
<span className="panel-subtitle">按最近修改时间排序</span> <span className="panel-subtitle">按最近修改时间排序</span>
</div> </div>
{checkedIds.length > 0 && (
<button type="button" className="btn btn-danger task-batch-delete" onClick={() => onDeleteMany(checkedIds)}>
<Trash2 className="icon" /> 删除所选({checkedIds.length})
</button>
)}
<label className="task-search"> <label className="task-search">
<Search className="icon" /> <Search className="icon" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务" aria-label="搜索任务" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务" aria-label="搜索任务" />
@ -128,7 +153,16 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
</div> </div>
<div className="task-table-head" aria-hidden="true"> <div className="task-table-head" aria-hidden="true">
<span>任务</span> <span className="task-name-head">
<input
type="checkbox"
className="task-check"
checked={allFilteredChecked}
onChange={toggleAllFiltered}
title="全选"
/>
任务
</span>
<span>状态</span> <span>状态</span>
<span>触发方式</span> <span>触发方式</span>
<span>最近运行</span> <span>最近运行</span>
@ -148,6 +182,14 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
}} }}
> >
<div className="task-name-cell"> <div className="task-name-cell">
<input
type="checkbox"
className="task-check"
checked={checkedIds.includes(flow.id)}
onChange={() => toggleChecked(flow.id)}
onClick={(event) => event.stopPropagation()}
aria-label={`选择任务 ${flow.name}`}
/>
<span className="task-icon"><ListChecks className="icon" /></span> <span className="task-icon"><ListChecks className="icon" /></span>
<span className="task-copy"><strong>{flow.name}</strong><small>{flow.nodeCount} 个节点</small></span> <span className="task-copy"><strong>{flow.name}</strong><small>{flow.nodeCount} 个节点</small></span>
</div> </div>

View File

@ -2860,6 +2860,28 @@ textarea:focus-visible {
box-shadow: inset 3px 0 0 var(--blue); box-shadow: inset 3px 0 0 var(--blue);
} }
/* 任务列表勾选框(单选/全选) */
.task-check {
width: 15px;
height: 15px;
flex: none;
margin: 0;
accent-color: var(--blue);
cursor: pointer;
}
.task-name-head {
display: flex;
align-items: center;
gap: 7px;
}
.task-batch-delete {
margin-left: 12px;
padding: 5px 12px;
font-size: 12.5px;
}
.task-name-cell, .task-name-cell,
.task-trigger, .task-trigger,
.task-updated, .task-updated,