控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
type AcquisitionPlan,
|
||
cursorPage,
|
||
Decision,
|
||
type DownloadTask,
|
||
PublicOrganizationRecord,
|
||
SourceRelease,
|
||
Task,
|
||
type TaskKind,
|
||
type TaskStatus,
|
||
} from "@umt/contracts";
|
||
import { recordPath, taskPath } from "~/components/console/record-sheet.tsx";
|
||
import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
|
||
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
|
||
import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts";
|
||
import { apiParsed } from "./parse.ts";
|
||
|
||
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
||
// 关联键全部由服务端给出:plan →(/tasks?subjectId=planId)submitDownload → download_task
|
||
// →(/tasks?subjectId=downloadTaskId)verifyAndImport →(/organization-records?downloadTaskId=)整理记录 → decision/agent_run。
|
||
|
||
const TaskPage = cursorPage(Task);
|
||
const RecordPage = cursorPage(PublicOrganizationRecord);
|
||
const DecisionPage = cursorPage(Decision);
|
||
|
||
const RESOLUTION_LABEL = {
|
||
unresolved: "未识别",
|
||
resolved: "已识别",
|
||
ambiguous: "匹配到多部作品,待确认",
|
||
unidentifiable: "无法识别",
|
||
rejected: "已排除",
|
||
} as const;
|
||
|
||
/** 时间线阶段:observation → plan → download → organization → agent */
|
||
export type TimelineStage = "observation" | "plan" | "download" | "organization" | "agent" | "engine";
|
||
export type TimelineTone = "success" | "warning" | "destructive" | "neutral";
|
||
|
||
export type TimelineEvent = {
|
||
key: string;
|
||
stage: TimelineStage;
|
||
at: string;
|
||
title: string;
|
||
detail?: string | null;
|
||
tone: TimelineTone;
|
||
href?: string;
|
||
/** 进行中的阶段(不是终态) */
|
||
ongoing?: boolean;
|
||
};
|
||
|
||
export function useSourceRelease(id: string | null | undefined) {
|
||
return useQuery({
|
||
queryKey: ["source-release", id ?? ""],
|
||
enabled: !!id,
|
||
queryFn: ({ signal }) =>
|
||
apiParsed(SourceRelease, "/api/v1/source-releases/:id", { pathParams: { id: id ?? "" }, signal }),
|
||
});
|
||
}
|
||
|
||
/** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */
|
||
export function useSubjectDecisions(subjectIds: string[]) {
|
||
const ids = subjectIds.filter(Boolean);
|
||
return useQuery({
|
||
queryKey: ["decisions", "subjects", ids],
|
||
enabled: ids.length > 0,
|
||
queryFn: ({ signal }) => apiParsed(DecisionPage, "/api/v1/decisions", { params: { limit: 200 }, signal }),
|
||
select: (d) => d.items.filter((x) => x.subjectId && ids.includes(x.subjectId)),
|
||
});
|
||
}
|
||
|
||
/** 业务任务:submitDownload 的 subject 是计划,verifyAndImport 的 subject 是下载任务(服务端写入 task 表) */
|
||
export function useTraceTasks(opts: { planId?: string | null; downloadTaskId?: string }) {
|
||
const ids = [opts.planId, opts.downloadTaskId].filter((x): x is string => !!x);
|
||
return useQuery({
|
||
queryKey: ["tasks", "trace", ids],
|
||
enabled: ids.length > 0,
|
||
queryFn: async ({ signal }): Promise<Task[]> => {
|
||
const pages = await Promise.all(
|
||
ids.map((subjectId) => apiParsed(TaskPage, "/api/v1/tasks", { params: { subjectId, limit: 50 }, signal })),
|
||
);
|
||
return pages.flatMap((p) => p.items);
|
||
},
|
||
refetchInterval: (q) => (q.state.data?.some((t) => ACTIVE_TASK.includes(t.status)) ? 5_000 : false),
|
||
});
|
||
}
|
||
|
||
const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
|
||
|
||
/** 这个下载产生的整理记录(服务端按 download_task_id 关联) */
|
||
export function useRecordsForDownload(downloadTaskId: string | undefined) {
|
||
return useQuery({
|
||
queryKey: ["records", "download", downloadTaskId ?? ""],
|
||
enabled: !!downloadTaskId,
|
||
queryFn: ({ signal }) =>
|
||
apiParsed(RecordPage, "/api/v1/organization-records", {
|
||
params: { downloadTaskId: downloadTaskId ?? "", limit: 50 },
|
||
signal,
|
||
}),
|
||
});
|
||
}
|
||
|
||
const TASK_STATUS_TEXT: Record<string, string> = {
|
||
pending: "排队中",
|
||
running: "运行中",
|
||
waiting: "等待中",
|
||
succeeded: "成功",
|
||
failed: "失败",
|
||
cancelled: "已取消",
|
||
dead: "放弃",
|
||
};
|
||
|
||
const TASK_KIND_LABEL: Partial<Record<TaskKind, string>> = {
|
||
submitDownload: "提交下载",
|
||
verifyAndImport: "核验整理",
|
||
evaluateAcquisition: "评估资源",
|
||
cleanupDownloadCache: "清理缓存",
|
||
resolveDecision: "处理需要处理的事项",
|
||
};
|
||
|
||
const TASK_STATUS_TONE: Record<TaskStatus, TimelineTone> = {
|
||
pending: "neutral",
|
||
running: "neutral",
|
||
waiting: "warning",
|
||
succeeded: "success",
|
||
failed: "destructive",
|
||
cancelled: "neutral",
|
||
dead: "destructive",
|
||
};
|
||
|
||
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
|
||
queued: "neutral",
|
||
running: "neutral",
|
||
succeeded: "success",
|
||
partial: "warning",
|
||
failed: "destructive",
|
||
cancelled: "neutral",
|
||
};
|
||
|
||
/** 汇总一个下载 trace 的业务事件,按时间升序 */
|
||
export function buildTimeline(input: {
|
||
task: DownloadTask;
|
||
release?: SourceRelease | null;
|
||
plan?: AcquisitionPlan | null;
|
||
records?: PublicOrganizationRecord[];
|
||
decisions?: Decision[];
|
||
tasks?: Task[];
|
||
}): TimelineEvent[] {
|
||
const { task, release, plan, records = [], decisions = [], tasks = [] } = input;
|
||
const ev: TimelineEvent[] = [];
|
||
if (release) {
|
||
ev.push({
|
||
key: "obs",
|
||
stage: "observation",
|
||
at: release.firstSeenAt,
|
||
title: "首次发现资源",
|
||
detail: release.title,
|
||
tone: "neutral",
|
||
});
|
||
if (release.resolutionStatus !== "unresolved")
|
||
ev.push({
|
||
key: "resolve",
|
||
stage: "observation",
|
||
// 合同里没有识别完成时间,挂在首次发现之后,只表达结果
|
||
at: release.firstSeenAt,
|
||
title:
|
||
release.resolutionStatus === "resolved"
|
||
? "识别结果:已关联本地作品"
|
||
: `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`,
|
||
tone: release.resolutionStatus === "resolved" ? "success" : "warning",
|
||
});
|
||
}
|
||
if (plan) {
|
||
ev.push({
|
||
key: "plan",
|
||
stage: "plan",
|
||
at: plan.createdAt,
|
||
title: `准备下载(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
||
detail: humanizeReason(plan.statusReason),
|
||
tone: "neutral",
|
||
});
|
||
if (plan.updatedAt !== plan.createdAt)
|
||
ev.push({
|
||
key: "plan-status",
|
||
stage: "plan",
|
||
at: plan.updatedAt,
|
||
title: `下载准备:${PLAN_STATUS_LABEL[plan.status]}`,
|
||
detail: humanizeReason(plan.statusReason),
|
||
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
||
});
|
||
}
|
||
ev.push({
|
||
key: "dl-submit",
|
||
stage: "download",
|
||
at: task.createdAt,
|
||
title: task.origin === "adopted" ? "开始管理已有下载" : "提交下载",
|
||
detail: task.name,
|
||
tone: "neutral",
|
||
});
|
||
if (task.pauseReason)
|
||
ev.push({
|
||
key: "dl-pause",
|
||
stage: "download",
|
||
at: task.updatedAt,
|
||
title: task.pauseReason === "threshold" ? "下载盘超过暂停线,已暂停" : PAUSE_REASON_LABEL[task.pauseReason],
|
||
tone: "warning",
|
||
ongoing: task.status === "paused",
|
||
});
|
||
if (task.noProgressSince)
|
||
ev.push({
|
||
key: "dl-stall",
|
||
stage: "download",
|
||
at: task.noProgressSince,
|
||
title: "无进展,不自动换源",
|
||
tone: "warning",
|
||
ongoing: task.status === "stalled",
|
||
});
|
||
if (task.completedAt)
|
||
ev.push({ key: "dl-done", stage: "download", at: task.completedAt, title: "下载完成", tone: "success" });
|
||
else if (task.status === "downloading")
|
||
ev.push({
|
||
key: "dl-progress",
|
||
stage: "download",
|
||
at: task.lastProgressAt ?? task.updatedAt,
|
||
title: `下载中 ${(task.progress * 100).toFixed(1)}%`,
|
||
tone: "neutral",
|
||
ongoing: true,
|
||
});
|
||
if (task.lastError)
|
||
ev.push({
|
||
key: "dl-err",
|
||
stage: "download",
|
||
at: task.updatedAt,
|
||
title: "下载器报错",
|
||
detail: humanizeReason(String((task.lastError as { message?: unknown }).message ?? "")) || null,
|
||
tone: "destructive",
|
||
});
|
||
for (const r of records) {
|
||
const c = r.counts;
|
||
ev.push({
|
||
key: `rec-${r.id}`,
|
||
stage: "organization",
|
||
at: r.startedAt ?? r.createdAt,
|
||
title: `整理 ${organizationActionLabel(r.action)}:${organizationLabel(r.status).label}`,
|
||
detail:
|
||
[
|
||
c.total ? `${c.succeeded}/${c.total} 个文件成功` : null,
|
||
c.failed ? `${c.failed} 个失败` : null,
|
||
c.skipped ? `${c.skipped} 个跳过` : null,
|
||
r.reason ? reasonLine(r.reason) : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ") || null,
|
||
tone: RECORD_TONE[r.status],
|
||
href: recordPath("organize", r.id),
|
||
ongoing: r.status === "running" || r.status === "queued",
|
||
});
|
||
}
|
||
for (const d of decisions) {
|
||
ev.push({
|
||
key: `dec-${d.id}`,
|
||
stage: "agent",
|
||
at: d.createdAt,
|
||
title: `需要处理:${d.title}`,
|
||
detail: d.summary,
|
||
tone: d.severity === "critical" ? "destructive" : "warning",
|
||
href: "/console/inbox",
|
||
});
|
||
if (d.agentRunId)
|
||
ev.push({
|
||
key: `agent-${d.id}`,
|
||
stage: "agent",
|
||
at: d.updatedAt,
|
||
title: "智能体已介入调查",
|
||
tone: "neutral",
|
||
href: `/console/agent/${d.agentRunId}`,
|
||
});
|
||
}
|
||
for (const t of tasks) {
|
||
const err = t.lastError
|
||
? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError))
|
||
: null;
|
||
ev.push({
|
||
key: `task-${t.id}`,
|
||
stage: "engine",
|
||
at: t.startedAt ?? t.createdAt,
|
||
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`,
|
||
detail:
|
||
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, humanizeReason(t.waitReason), humanizeReason(err)]
|
||
.filter(Boolean)
|
||
.join(" · ") || null,
|
||
tone: TASK_STATUS_TONE[t.status],
|
||
href: taskPath(t.id),
|
||
ongoing: ACTIVE_TASK.includes(t.status),
|
||
});
|
||
}
|
||
return ev.sort((a, b) => a.at.localeCompare(b.at));
|
||
}
|
||
|
||
/** 一站式取 trace 数据 */
|
||
export function useDownloadTrace(task: DownloadTask | undefined, plan: AcquisitionPlan | null | undefined) {
|
||
const release = useSourceRelease(task?.sourceReleaseId ?? plan?.sourceReleaseId);
|
||
const records = useRecordsForDownload(task?.id);
|
||
const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]);
|
||
const tasks = useTraceTasks({ planId: task?.acquisitionPlanId, downloadTaskId: task?.id });
|
||
return {
|
||
release,
|
||
records,
|
||
decisions,
|
||
tasks,
|
||
events: task
|
||
? buildTimeline({
|
||
task,
|
||
release: release.data,
|
||
plan,
|
||
records: records.data?.items ?? [],
|
||
decisions: decisions.data,
|
||
tasks: tasks.data,
|
||
})
|
||
: [],
|
||
loading: release.isLoading || records.isLoading || decisions.isLoading || tasks.isLoading,
|
||
};
|
||
}
|