feat(ui): 任务列表操作列增加详情与暂停/继续按钮
操作列此前只有删除,暂停/继续必须先勾选再点批量控制,进详情页只能点标题。 - 详情按钮用 Button asChild 包 Link,渲染为真正的 <a>,中键新开标签、 右键复制链接都可用 - 暂停/继续按钮按状态自动切换形态,调已有的 controlTask 接口,无需改后端; 终态任务渲染为 disabled 而非隐藏,各行操作列宽度才不会随状态跳动 - 状态门控与后端 applyTaskControlWithCause 对齐:非终态且未暂停可暂停 (即 created/queued/running),paused 可继续,不会给出后端会拒绝的动作 - 控制后清空 lastRef 再 load:列表轮询按负载去重,不清会把状态回写挡掉, 看起来像点了没反应;放在 finally 里是因为失败多为状态已变化,更需要重拉 - 批量控制原本自己写了一遍状态列表,改为与行内按钮共用 taskControlAction(), 避免将来新增状态时两边判断分歧 Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy
This commit is contained in:
1 parent
76c648d36d
commit
ca7300ad52
1 file changed
+86
-6
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
EyeIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PauseIcon,
|
||||
@@ -198,6 +199,17 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
|
||||
{ value: "timeout", label: "已超时" },
|
||||
];
|
||||
|
||||
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
|
||||
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
|
||||
// 两边不会出现一个可点、另一个不可点的分歧。
|
||||
const PAUSABLE_STATUSES = new Set<TaskStatus>(["created", "queued", "running"]);
|
||||
|
||||
function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
|
||||
if (status === "paused") return "resume";
|
||||
if (PAUSABLE_STATUSES.has(status)) return "pause";
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -332,6 +344,27 @@ export default function TasksPage() {
|
||||
[load],
|
||||
);
|
||||
|
||||
// controlTask 是行内暂停/继续:批量走 controlTasksBatch,单行走单任务接口,省去
|
||||
// 「先勾选再点批量」。清空 lastRef 让下一次轮询即使负载相同也照单接收,否则状态
|
||||
// 回写会被去重挡掉、按钮看起来没反应。
|
||||
const controlTask = React.useCallback(
|
||||
async (id: string, action: "pause" | "resume") => {
|
||||
try {
|
||||
const result = await api.controlTask(id, action);
|
||||
toast.success(
|
||||
action === "pause" ? `任务 #${id} 已暂停` : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(`${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
// 无论成败都刷新:失败多半是状态已变化,重新拉取才能让按钮回到正确形态。
|
||||
lastRef.current = "";
|
||||
load();
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
|
||||
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
|
||||
const deleteTasks = React.useCallback(
|
||||
@@ -393,14 +426,11 @@ export default function TasksPage() {
|
||||
|
||||
const selectedTasks = React.useMemo(() => tasks.filter((task) => selectedIds.has(task.id)), [tasks, selectedIds]);
|
||||
const pausableTaskIDs = React.useMemo(
|
||||
() =>
|
||||
selectedTasks
|
||||
.filter((task) => task.status === "created" || task.status === "queued" || task.status === "running")
|
||||
.map((task) => task.id),
|
||||
() => selectedTasks.filter((task) => taskControlAction(task.status) === "pause").map((task) => task.id),
|
||||
[selectedTasks],
|
||||
);
|
||||
const resumableTaskIDs = React.useMemo(
|
||||
() => selectedTasks.filter((task) => task.status === "paused").map((task) => task.id),
|
||||
() => selectedTasks.filter((task) => taskControlAction(task.status) === "resume").map((task) => task.id),
|
||||
[selectedTasks],
|
||||
);
|
||||
|
||||
@@ -566,6 +596,7 @@ export default function TasksPage() {
|
||||
// 带来的整表重渲染,只让在跑的那几行走时长。
|
||||
nowSec={task.status === "running" ? nowSec : 0}
|
||||
onDelete={deleteTask}
|
||||
onControl={controlTask}
|
||||
selected={selectedIds.has(task.id)}
|
||||
onSelectedChange={toggleSelected}
|
||||
/>
|
||||
@@ -682,12 +713,14 @@ const TaskRow = React.memo(function TaskRow({
|
||||
task,
|
||||
nowSec,
|
||||
onDelete,
|
||||
onControl,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
}: {
|
||||
task: Task;
|
||||
nowSec: number;
|
||||
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
|
||||
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
|
||||
selected: boolean;
|
||||
onSelectedChange: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
@@ -761,7 +794,13 @@ const TaskRow = React.memo(function TaskRow({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button size="icon" variant="ghost" asChild aria-label="查看任务详情" title="查看任务详情">
|
||||
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}>
|
||||
<EyeIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<TaskControlButton task={task} onControl={onControl} />
|
||||
<DeleteTaskDialog task={task} onDelete={onDelete} />
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -769,6 +808,47 @@ const TaskRow = React.memo(function TaskRow({
|
||||
);
|
||||
});
|
||||
|
||||
// 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。
|
||||
function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) {
|
||||
if (pending) return <Loader2Icon className="animate-spin" />;
|
||||
if (action === "resume") return <PlayIcon />;
|
||||
return <PauseIcon />;
|
||||
}
|
||||
|
||||
// TaskControlButton 是行内的暂停/继续开关。终态任务渲染为 disabled 而不是隐藏,
|
||||
// 这样各行操作列宽度一致,按钮位置不会随状态跳动。
|
||||
function TaskControlButton({
|
||||
task,
|
||||
onControl,
|
||||
}: {
|
||||
task: Task;
|
||||
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
|
||||
}) {
|
||||
const [pending, setPending] = React.useState(false);
|
||||
const action = taskControlAction(task.status);
|
||||
const label = action === "resume" ? "继续任务" : "暂停任务";
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={label}
|
||||
title={action ? label : "该状态不可暂停或继续"}
|
||||
disabled={!action || pending}
|
||||
onClick={async () => {
|
||||
if (!action) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await onControl(task.id, action);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{taskControlIcon(pending, action)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyDeleteOptions = (): DeleteTaskOptions => ({
|
||||
delete_assets: false,
|
||||
delete_traffic: false,
|
||||
|
||||
Reference in new issue
Block a user