- Sidebar: 概览/资源/订阅/下载/整理/Agent/系统, 18 items (+ 定时任务); single active item via findNavItem; 待处理 badge; collapsed rail groups; mobile drawer shares NAV_GROUPS. - New pages: /downloads (queue + cache watermark header), /downloads/plans, /downloads/:id, /tasks (business tasks only) + /tasks/:id detail, /rules. - Legacy links: ?tab= on tasks/subscriptions/sources redirect via clientLoader; /tasks/:downloadId falls back to /downloads/:id. - List filters in URL query; settingsPath() helper for settings deep links. - Copy per glossary (待处理, 收录策略, 引擎面板, 文件管理, 版本目标). Co-Authored-By: Claude Opus 5.5 <[email protected]>
300 lines
10 KiB
TypeScript
300 lines
10 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 { 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_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: 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: 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" ? "缓存达到硬阈值,已暂停" : `已暂停(${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: String((task.lastError as { message?: unknown }).message ?? JSON.stringify(task.lastError)),
|
|
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: `整理 ${r.action === "copy" ? "复制" : r.action === "move" ? "移动" : r.action}:${r.status}`,
|
|
detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`,
|
|
tone: RECORD_TONE[r.status],
|
|
href: `/console/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: "Agent 已介入调查",
|
|
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}:${t.status}`,
|
|
detail:
|
|
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
|
|
tone: TASK_STATUS_TONE[t.status],
|
|
href: `/console/tasks/${encodeURIComponent(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,
|
|
};
|
|
}
|