Files
UHD_Media_Tool/apps/web/app/components/tasks/legacy-task.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

170 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from "@tanstack/react-query";
import { AssetOperation, DownloadTask, Task } from "@umt/contracts";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router";
import { z } from "zod";
import { apiParsed, isMissingEndpoint } from "~/api/parse.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { RecordLink, recordPath } from "~/components/console/record-sheet.tsx";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { Timeline } from "~/components/console/timeline.tsx";
import { TASK_KIND_LABEL, TaskActions, TaskStatusLine, TaskSummary } from "~/components/downloads/business-tasks.tsx";
import { TTL_SECOND } from "~/lib/api.ts";
// 日志页任务抽屉打开的 id 不是引擎运行时(旧链接 /console/tasks/:id):先当旧业务任务查,
// 有引擎运行就换成那条;没有就在抽屉里显示旧任务的概况与文件操作;再查不到当下载任务 id,换到下载面板的抽屉。
const OP_TONE: Record<AssetOperation["status"], StatusTone> = {
planned: "neutral",
started: "neutral",
succeeded: "success",
failed: "destructive",
skipped: "neutral",
compensated: "warning",
};
const OP_STATUS: Record<AssetOperation["status"], string> = {
planned: "待执行",
started: "进行中",
succeeded: "成功",
failed: "失败",
skipped: "已跳过",
compensated: "已回滚",
};
const OP_PHASE: Record<AssetOperation["phase"], string> = {
decision: "判定",
prepare: "准备",
transfer: "传输",
verify: "校验",
commit: "提交",
cleanup: "清理",
compensate: "回滚",
};
const OP_KIND: Record<AssetOperation["kind"], string> = {
copy: "复制",
move: "移动",
rename: "改名",
mkdir: "建目录",
hardlink: "硬链接",
delete_source: "删除源文件",
delete_target: "删除目标文件",
verify: "校验",
register: "登记",
qb_pause: "暂停下载",
qb_resume: "恢复下载",
qb_remove: "移除下载",
qb_set_tags: "改下载标签",
other: "其他",
};
function useTask(id: string) {
return useQuery({
queryKey: ["tasks", "one", id],
retry: false,
queryFn: ({ signal }) => apiParsed(Task, "/api/v1/tasks/:id", { pathParams: { id }, signal }),
refetchInterval: (q) =>
q.state.data && ["pending", "running", "waiting"].includes(q.state.data.status) ? 5 * TTL_SECOND : false,
});
}
function useLegacyDownload(id: string, enabled: boolean) {
return useQuery({
queryKey: ["downloads", "legacy-probe", id],
enabled,
retry: false,
queryFn: ({ signal }) => apiParsed(DownloadTask, "/api/v1/download-tasks/:id", { pathParams: { id }, signal }),
});
}
function Operations({ taskId }: { taskId: string }) {
const ops = useQuery({
queryKey: ["tasks", "operations", taskId],
queryFn: ({ signal }) =>
apiParsed(z.object({ items: z.array(AssetOperation) }), "/api/v1/tasks/:id/operations", {
pathParams: { id: taskId },
signal,
}),
});
if (ops.isPending) return <LoadingState rows={3} />;
if (ops.error) return <ErrorState plain error={ops.error} onRetry={() => void ops.refetch()} />;
if (!ops.data.items.length) return <p className="text-caption text-muted-foreground">这个任务没有文件操作。</p>;
return (
<Timeline
items={ops.data.items.map((o) => ({
id: o.id,
at: o.startedAt ?? o.createdAt,
tone: OP_TONE[o.status],
title: `${OP_PHASE[o.phase]} · ${OP_KIND[o.kind]}:${OP_STATUS[o.status]}`,
meta: o.attempts > 1 ? `${o.attempts} 次` : undefined,
description: o.organizationRecordId ? (
<RecordLink page="organize" id={o.organizationRecordId} className="underline underline-offset-4">
整理记录 {o.organizationRecordId.slice(0, 8)}
</RecordLink>
) : o.downloadTaskId ? (
<RecordLink page="downloads" id={o.downloadTaskId} className="underline underline-offset-4">
下载任务 {o.downloadTaskId.slice(0, 8)}
</RecordLink>
) : undefined,
}))}
/>
);
}
/** 引擎运行查不到时的任务抽屉 */
export function LegacyTaskSheet({
id,
runError,
onOpenRun,
onClose,
}: {
id: string;
/** 引擎运行查询的错误(不是「不存在」时直接显示) */
runError: unknown;
onOpenRun: (runId: string) => void;
onClose: () => void;
}) {
const navigate = useNavigate();
const runMissing = isMissingEndpoint(runError);
const task = useTask(id);
const notFound = !!task.error && isMissingEndpoint(task.error);
const legacy = useLegacyDownload(id, runMissing && notFound);
const t = task.data;
const engineRunId = t?.engineRunId;
const openRun = useRef(onOpenRun);
openRun.current = onOpenRun;
useEffect(() => {
if (engineRunId) openRun.current(engineRunId);
}, [engineRunId]);
const downloadId = legacy.data?.id;
useEffect(() => {
if (downloadId) navigate(recordPath("downloads", downloadId), { replace: true });
}, [downloadId, navigate]);
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
width="lg"
title={t ? (TASK_KIND_LABEL[t.kind] ?? t.kind) : "任务"}
description={t ? <TaskStatusLine t={t} /> : undefined}
footer={t ? <TaskActions t={t} /> : null}
>
{!runMissing ? (
<ErrorState plain error={runError} />
) : task.isPending || (notFound && legacy.isPending) || engineRunId || downloadId ? (
<LoadingState rows={5} />
) : !t ? (
<ErrorState plain error={task.error} onRetry={() => void task.refetch()} />
) : (
<>
<TaskSummary t={t} />
<DetailSection title="文件操作">
<Operations taskId={t.id} />
</DetailSection>
</>
)}
</DetailSheet>
);
}