feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -0,0 +1,222 @@
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AgentRun,
|
||||
type AgentRunEvent,
|
||||
AgentRunEventRow,
|
||||
type AgentRunStatus,
|
||||
agentRunEventFromRow,
|
||||
cursorPage,
|
||||
Id,
|
||||
NonNegInt,
|
||||
Timestamp,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
|
||||
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort)。
|
||||
// 端点未就绪时用 mock 开关(?mock=1 或 VITE_AGENT_MOCK=1)开发。
|
||||
// 下面的 schema 与 contracts/agent.ts 的 AgentRunDetail / AgentGoalReport 同形;在这里宽松镜像一份,
|
||||
// 合同字段调整时页面仍能解析(新增字段忽略,缺失的可选字段按未知显示)。
|
||||
|
||||
export const AGENT_BASE = "/api/v1/agent/runs";
|
||||
|
||||
/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */
|
||||
export const AgentMode = z.enum(["execute", "plan_only"]);
|
||||
export type AgentMode = z.infer<typeof AgentMode>;
|
||||
|
||||
export const AgentRunView = AgentRun.extend({
|
||||
mode: AgentMode.optional(),
|
||||
dedupeKey: z.string().nullable().optional(),
|
||||
});
|
||||
export type AgentRunView = z.infer<typeof AgentRunView>;
|
||||
|
||||
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
||||
export const GoalReport = z.object({
|
||||
state: z.enum(["no_actions", "waiting", "completed", "partial", "failed"]),
|
||||
summary: z.string(),
|
||||
submitted: NonNegInt,
|
||||
imported: NonNegInt,
|
||||
inProgress: NonNegInt,
|
||||
failed: NonNegInt,
|
||||
plans: z.array(z.object({ planId: Id, status: z.string() }).passthrough()),
|
||||
downloads: z.array(z.object({ downloadTaskId: Id, status: z.string(), name: z.string().nullable() }).passthrough()),
|
||||
organization: z.array(
|
||||
z.object({ recordId: Id, status: z.string(), targetPath: z.string().nullable() }).passthrough(),
|
||||
),
|
||||
modelText: z.string().nullable(),
|
||||
generatedAt: Timestamp,
|
||||
});
|
||||
export type GoalReport = z.infer<typeof GoalReport>;
|
||||
|
||||
export const RunMessage = z.object({
|
||||
id: Id,
|
||||
createdAt: Timestamp,
|
||||
text: z.string(),
|
||||
status: z.string(),
|
||||
consumedAt: Timestamp.nullable().optional(),
|
||||
});
|
||||
export type RunMessage = z.infer<typeof RunMessage>;
|
||||
|
||||
export const RunDetail = z.object({
|
||||
run: AgentRunView,
|
||||
events: z.array(AgentRunEventRow),
|
||||
nextAfterSeq: z.number().int().nullable(),
|
||||
messages: z.array(RunMessage),
|
||||
report: GoalReport.nullable(),
|
||||
});
|
||||
export type RunDetail = z.infer<typeof RunDetail>;
|
||||
export type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
|
||||
|
||||
const RunPage = cursorPage(AgentRunView);
|
||||
|
||||
export function agentMockEnabled(): boolean {
|
||||
if (import.meta.env.VITE_AGENT_MOCK === "1") return true;
|
||||
if (typeof window === "undefined") return false;
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
if (p.get("mock") === "1") sessionStorage.setItem("umt-agent-mock", "1");
|
||||
if (p.get("mock") === "0") sessionStorage.removeItem("umt-agent-mock");
|
||||
return sessionStorage.getItem("umt-agent-mock") === "1";
|
||||
} catch {
|
||||
return p.get("mock") === "1";
|
||||
}
|
||||
}
|
||||
|
||||
export const agentKeys = {
|
||||
all: ["agent"] as const,
|
||||
runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const,
|
||||
run: (id: string) => ["agent", "detail", id] as const,
|
||||
};
|
||||
|
||||
const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"];
|
||||
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
|
||||
|
||||
export function useAgentRuns(status?: AgentRunStatus) {
|
||||
const mock = agentMockEnabled();
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...agentKeys.runs(status), mock],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null };
|
||||
return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal });
|
||||
},
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages.some((p) => p.items.some((r) => isRunActive(r.status))) ? 5 * TTL_SECOND : false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 取一个足够大的 afterSeq,只拿 run / 追加消息 / 目标汇报,不拉事件(事件由 useAgentStream 增量读) */
|
||||
const NO_EVENTS = 2 ** 31 - 1;
|
||||
|
||||
export function useAgentRun(id: string | undefined) {
|
||||
const mock = agentMockEnabled();
|
||||
return useQuery({
|
||||
queryKey: [...agentKeys.run(id ?? ""), mock],
|
||||
enabled: !!id,
|
||||
queryFn: async ({ signal }): Promise<RunSnapshot> => {
|
||||
if (mock) return mockDetail(id ?? "");
|
||||
const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, {
|
||||
pathParams: { id: id ?? "" },
|
||||
params: { afterSeq: NO_EVENTS, limit: 1 },
|
||||
signal,
|
||||
});
|
||||
return { run: d.run, messages: d.messages, report: d.report };
|
||||
},
|
||||
refetchInterval: (q) => (isRunActive(q.state.data?.run.status) ? 3 * TTL_SECOND : false),
|
||||
});
|
||||
}
|
||||
|
||||
/** 事件回放:after 为已拿到的最大 seq(-1 = 从头);按 nextAfterSeq 翻页直到最新 */
|
||||
export async function fetchRunEvents(id: string, after: number, signal?: AbortSignal): Promise<AgentRunEvent[]> {
|
||||
if (agentMockEnabled()) return mockEventsAfter(id, after);
|
||||
const out: AgentRunEvent[] = [];
|
||||
let cursor = after;
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, {
|
||||
pathParams: { id },
|
||||
params: { afterSeq: cursor, limit: 500 },
|
||||
signal,
|
||||
});
|
||||
for (const row of d.events) {
|
||||
try {
|
||||
out.push(agentRunEventFromRow(row));
|
||||
} catch {
|
||||
// 未知/不合法事件跳过,不让一条坏事件打断整段回放
|
||||
}
|
||||
}
|
||||
if (d.nextAfterSeq === null || d.nextAfterSeq <= cursor) break;
|
||||
cursor = d.nextAfterSeq;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function useCreateAgentRun() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { goal: string; mode: AgentMode }) => {
|
||||
if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode);
|
||||
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRunView.parse(d));
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAbortAgentRun() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (agentMockEnabled()) return mockDetail(id, { abort: true }).run;
|
||||
return apiMutate(`${AGENT_BASE}/:id/abort`, { pathParams: { id }, body: { reason: "用户在控制台中止" } }).then(
|
||||
(d) => AgentRunView.parse(d),
|
||||
);
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppendAgentMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, text }: { id: string; text: string }) => {
|
||||
if (agentMockEnabled()) return mockDetail(id, { append: text }).run;
|
||||
return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id }, body: { text } }).then((d) =>
|
||||
AgentRunView.parse(d),
|
||||
);
|
||||
},
|
||||
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.id) }),
|
||||
});
|
||||
}
|
||||
|
||||
export const RUN_STATUS_LABEL: Record<AgentRunStatus, string> = {
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
waiting: "等待中",
|
||||
succeeded: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已中止",
|
||||
budget_exceeded: "超出预算",
|
||||
};
|
||||
|
||||
export const RUN_STATUS_TONE: Record<AgentRunStatus, "success" | "warning" | "destructive" | "neutral"> = {
|
||||
queued: "neutral",
|
||||
running: "neutral",
|
||||
waiting: "warning",
|
||||
succeeded: "success",
|
||||
failed: "destructive",
|
||||
cancelled: "neutral",
|
||||
budget_exceeded: "warning",
|
||||
};
|
||||
|
||||
export const REPORT_STATE: Record<
|
||||
GoalReport["state"],
|
||||
{ label: string; tone: "success" | "warning" | "destructive" | "neutral" }
|
||||
> = {
|
||||
no_actions: { label: "没有产生业务动作", tone: "neutral" },
|
||||
waiting: { label: "已提交,等待下载/入库", tone: "warning" },
|
||||
completed: { label: "已核验入库", tone: "success" },
|
||||
partial: { label: "部分完成", tone: "warning" },
|
||||
failed: { label: "未达成", tone: "destructive" },
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AcquisitionPlan,
|
||||
cursorPage,
|
||||
Dashboard,
|
||||
DownloaderInstance,
|
||||
type DownloadStatus,
|
||||
DownloadTask,
|
||||
type PlanStatus,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
|
||||
// 下载任务、下载计划、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
|
||||
|
||||
const DownloadPage = cursorPage(DownloadTask);
|
||||
const PlanPage = cursorPage(AcquisitionPlan);
|
||||
|
||||
export type DownloadFilters = { status?: DownloadStatus; instanceId?: string; managed?: "true" | "false" };
|
||||
|
||||
export const downloadKeys = {
|
||||
all: ["downloads"] as const,
|
||||
list: (f: DownloadFilters) => ["downloads", "list", f] as const,
|
||||
one: (id: string) => ["downloads", "one", id] as const,
|
||||
plans: (status?: PlanStatus) => ["downloads", "plans", status ?? "all"] as const,
|
||||
plan: (id: string) => ["downloads", "plan", id] as const,
|
||||
cache: () => ["downloads", "cache"] as const,
|
||||
};
|
||||
|
||||
const ACTIVE: DownloadStatus[] = ["queued", "submitting", "downloading", "importing", "stalled"];
|
||||
|
||||
export function useDownloadTasks(f: DownloadFilters) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: downloadKeys.list(f),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
apiParsed(DownloadPage, "/api/v1/download-tasks", {
|
||||
params: cleanParams({ ...f, cursor: pageParam, limit: 50 }),
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
// 有进行中的任务时 10 秒轮询进度(SSE download.progress 到达时也会失效重取)
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 10 * TTL_SECOND : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDownloadTask(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: downloadKeys.one(id ?? ""),
|
||||
enabled: !!id,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(DownloadTask, "/api/v1/download-tasks/:id", { pathParams: { id: id ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlans(status?: PlanStatus) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: downloadKeys.plans(status),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
apiParsed(PlanPage, "/api/v1/acquisition-plans", {
|
||||
params: cleanParams({ status, cursor: pageParam, limit: 50 }),
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlan(id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: downloadKeys.plan(id ?? ""),
|
||||
enabled: !!id,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(AcquisitionPlan, "/api/v1/acquisition-plans/:id", { pathParams: { id: id ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 暂停(记为用户暂停)/ 恢复;阈值暂停在水位未回落前服务端拒绝恢复 */
|
||||
export function useDownloadAction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, action }: { id: string; action: "pause" | "resume" }) =>
|
||||
apiMutate(`/api/v1/download-tasks/:id/${action}`, { pathParams: { id } }).then((d) => DownloadTask.parse(d)),
|
||||
onSuccess: (d) => {
|
||||
qc.setQueryData(downloadKeys.one(d.id), d);
|
||||
qc.invalidateQueries({ queryKey: ["downloads", "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 缓存卷用量/阈值:取首页聚合里的 cache 区块(同一统计口径) */
|
||||
export function useCacheVolumes() {
|
||||
return useQuery({
|
||||
queryKey: downloadKeys.cache(),
|
||||
queryFn: ({ signal }) => apiParsed(Dashboard, "/api/v1/dashboard", { signal }),
|
||||
select: (d) => d.cache.volumes,
|
||||
refetchInterval: 30 * TTL_SECOND,
|
||||
});
|
||||
}
|
||||
|
||||
/** 手动清理:只清已验证入库且满足保留/H&R 的纳管任务 */
|
||||
export function useCacheCleanup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (downloaderInstanceId?: string) =>
|
||||
apiMutate("/api/v1/download-cache/cleanup", {
|
||||
body: downloaderInstanceId ? { downloaderInstanceId } : {},
|
||||
}).then((d) => z.object({ report: z.record(z.string(), z.unknown()) }).parse(d)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: downloadKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 下载器实例名(列表展示用) */
|
||||
export function useDownloaderNames() {
|
||||
return useQuery({
|
||||
queryKey: ["downloads", "instances"],
|
||||
staleTime: 5 * 60 * TTL_SECOND,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", { params: { limit: 200 }, signal }),
|
||||
select: (d) => new Map(d.items.map((i) => [i.id, i.name])),
|
||||
});
|
||||
}
|
||||
|
||||
export const DOWNLOAD_STATUS_LABEL: Record<DownloadStatus, string> = {
|
||||
queued: "排队",
|
||||
submitting: "提交中",
|
||||
downloading: "下载中",
|
||||
paused: "已暂停",
|
||||
stalled: "无进展",
|
||||
completed: "下载完成",
|
||||
importing: "入库中",
|
||||
imported: "已入库",
|
||||
failed: "失败",
|
||||
removed: "已移除",
|
||||
missing: "下载器中缺失",
|
||||
};
|
||||
|
||||
export const DOWNLOAD_STATUS_TONE: Record<DownloadStatus, "success" | "warning" | "destructive" | "neutral"> = {
|
||||
queued: "neutral",
|
||||
submitting: "neutral",
|
||||
downloading: "neutral",
|
||||
paused: "warning",
|
||||
stalled: "warning",
|
||||
completed: "success",
|
||||
importing: "neutral",
|
||||
imported: "success",
|
||||
failed: "destructive",
|
||||
removed: "neutral",
|
||||
missing: "destructive",
|
||||
};
|
||||
|
||||
export const PAUSE_REASON_LABEL = {
|
||||
user: "用户暂停",
|
||||
threshold: "阈值暂停",
|
||||
policy: "策略暂停",
|
||||
agent: "Agent 暂停",
|
||||
} as const;
|
||||
|
||||
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
|
||||
draft: "草稿",
|
||||
reserved: "已预留",
|
||||
submitted: "已提交",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
failed: "失败",
|
||||
expired: "已过期",
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
cursorPage,
|
||||
type FeedHealth,
|
||||
FeedSource,
|
||||
type FeedSourceCreate,
|
||||
type FeedSourceUpdate,
|
||||
Id,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
|
||||
// Feed 来源(W2-B:/feed-sources)
|
||||
|
||||
const FeedPage = cursorPage(FeedSource);
|
||||
|
||||
export const feedKeys = {
|
||||
all: ["feeds"] as const,
|
||||
list: (f: { siteId?: string; health?: FeedHealth }) => ["feeds", "list", f] as const,
|
||||
};
|
||||
|
||||
export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
|
||||
return useQuery({
|
||||
queryKey: feedKeys.list(f),
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), signal }),
|
||||
refetchInterval: 30 * TTL_SECOND,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFeed() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: FeedSourceCreate) =>
|
||||
apiMutate("/api/v1/feed-sources", { body }).then((d) => FeedSource.parse(d)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFeed() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: FeedSourceUpdate }) =>
|
||||
apiMutate("/api/v1/feed-sources/:id", { method: "PATCH", pathParams: { id }, body }).then((d) =>
|
||||
FeedSource.parse(d),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 启停 / 熔断复位 */
|
||||
export function useFeedAction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, action }: { id: string; action: "enable" | "disable" | "reset" }) =>
|
||||
apiMutate(`/api/v1/feed-sources/:id/${action}`, { pathParams: { id } }).then((d) => FeedSource.parse(d)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 立即抓取(仍受站点限流),返回任务 ID */
|
||||
export function useFetchFeedNow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate("/api/v1/feed-sources/:id/fetch", { pathParams: { id } }).then((d) =>
|
||||
z.object({ taskId: Id }).parse(d),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: feedKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 疑似缺口:Feed 窗口与上次零重叠时服务端发 feed.gap_suspected 并排补漏。
|
||||
* 列表字段里没有显式标记,这里按 cursor 里的 gapSuspectedAt(若服务端写入)或"距上次成功超过 3 个周期"推断,并标"疑似"。
|
||||
*/
|
||||
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
|
||||
const gapAt = typeof f.cursor.gapSuspectedAt === "string" ? f.cursor.gapSuspectedAt : null;
|
||||
if (gapAt) return `疑似缺口(${new Date(gapAt).toLocaleString("zh-CN", { hour12: false })} 检测,已排补漏)`;
|
||||
if (!f.enabled || !f.lastSuccessAt) return null;
|
||||
const gapMs = now - new Date(f.lastSuccessAt).getTime();
|
||||
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项(疑似)";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type InventoryEntry, InventoryResponse, LibraryDetail } from "@umt/contracts";
|
||||
import { apiParsed } from "./parse.ts";
|
||||
|
||||
// 站点工作台库存叠加(W2-C:/inventory?entityIds=,最多 200 个)
|
||||
|
||||
export function useInventory(entityIds: string[]) {
|
||||
const ids = [...new Set(entityIds)].sort().slice(0, 200);
|
||||
return useQuery({
|
||||
queryKey: ["inventory", ids],
|
||||
enabled: ids.length > 0,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(InventoryResponse, "/api/v1/inventory", { params: { entityIds: ids.join(",") }, signal }),
|
||||
select: (d) => new Map<string, InventoryEntry>(d.items.map((i) => [i.entityId, i])),
|
||||
});
|
||||
}
|
||||
|
||||
/** 叠加标记:只来自本地身份/Slot/任务,不从网页标题猜确定事实 */
|
||||
export type OverlayFlag = { key: string; label: string; tone: "success" | "warning" | "neutral" };
|
||||
|
||||
export function inventoryFlags(entry: InventoryEntry | undefined, releaseResolution?: string | null): OverlayFlag[] {
|
||||
if (!entry) return [];
|
||||
const flags: OverlayFlag[] = [];
|
||||
const available = entry.versions.filter((v) => v.available);
|
||||
if (available.length) {
|
||||
const res = [...new Set(available.map((v) => v.resolution ?? "未知分辨率"))].join(" / ");
|
||||
flags.push({ key: "have", label: `已有 ${res}`, tone: "success" });
|
||||
}
|
||||
const has4k = available.some((v) => /2160|4k|uhd/i.test(v.resolution ?? ""));
|
||||
const wants4k = entry.gaps.some((g) => /2160|4k|uhd/i.test(g.name));
|
||||
if (wants4k && !has4k) flags.push({ key: "no4k", label: "缺 4K", tone: "warning" });
|
||||
for (const g of entry.gaps.filter((g) => !/2160|4k|uhd/i.test(g.name) || has4k)) {
|
||||
flags.push({ key: `gap-${g.slotId}`, label: `缺口:${g.name}`, tone: "warning" });
|
||||
}
|
||||
if (entry.downloading.length) {
|
||||
flags.push({ key: "dl", label: `下载中 ${entry.downloading.length}`, tone: "neutral" });
|
||||
}
|
||||
if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) {
|
||||
flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" });
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
/** 作品详情(候选理由、Slot、下载历史);工作台详情抽屉用它回答"为什么选/不选" */
|
||||
export function useEntityDetail(entityId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["library", "detail", entityId ?? ""],
|
||||
enabled: !!entityId,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(LibraryDetail, "/api/v1/library/:id", { pathParams: { id: entityId ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ApiErrorCode } from "@umt/contracts";
|
||||
import type { z } from "zod";
|
||||
import { ApiRequestError, apiFetch, type FetcherArg } from "~/lib/api.ts";
|
||||
|
||||
export { ApiRequestError };
|
||||
|
||||
/** 请求并用 contracts 的 zod schema 校验 data;失败抛 ApiRequestError(给 react-query 的 error 态) */
|
||||
export async function apiParsed<S extends z.ZodType>(schema: S, path: string, arg?: FetcherArg): Promise<z.infer<S>> {
|
||||
const r = await apiFetch<unknown>(path, arg);
|
||||
if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message);
|
||||
const parsed = schema.safeParse(r.data);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
throw new ApiRequestError(
|
||||
"internal" satisfies ApiErrorCode,
|
||||
`响应格式与合同不符:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** 变更请求:返回 data,失败抛错(给 useMutation,由 onError 统一 toast) */
|
||||
export async function apiMutate<T = unknown>(path: string, arg: FetcherArg = {}): Promise<T> {
|
||||
const r = await apiFetch<T>(path, { method: "POST", ...arg });
|
||||
if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export const errorCode = (err: unknown): string | null => (err instanceof ApiRequestError ? err.code : null);
|
||||
|
||||
/** 端点是否不存在(后端尚未提供) */
|
||||
export const isMissingEndpoint = (err: unknown) => errorCode(err) === "not_found";
|
||||
|
||||
/** 查询字符串:去掉空值 */
|
||||
export function cleanParams(p: Record<string, string | number | boolean | undefined | null>) {
|
||||
const out: Record<string, string | number | boolean> = {};
|
||||
for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Artifact,
|
||||
cursorPage,
|
||||
LibraryItem,
|
||||
LibraryRoot,
|
||||
type OrganizationStatus,
|
||||
type OrganizationTrigger,
|
||||
OrganizeAccepted,
|
||||
type OrganizeRequest,
|
||||
PublicOrganizationRecord,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
|
||||
// 整理记录(W2-C:/organization-records;读 organize.read,发起 organize.execute)
|
||||
|
||||
const RecordPage = cursorPage(PublicOrganizationRecord);
|
||||
|
||||
export type RecordFilters = {
|
||||
status?: OrganizationStatus;
|
||||
trigger?: OrganizationTrigger;
|
||||
mediaEntityId?: string;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export const recordKeys = {
|
||||
all: ["records"] as const,
|
||||
list: (f: RecordFilters) => ["records", "list", f] as const,
|
||||
one: (id: string) => ["records", "one", id] as const,
|
||||
entity: (id: string) => ["records", "entity", id] as const,
|
||||
};
|
||||
|
||||
export function useRecords(f: RecordFilters) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: recordKeys.list(f),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
apiParsed(RecordPage, "/api/v1/organization-records", {
|
||||
params: cleanParams({ ...f, cursor: pageParam, limit: 50 }),
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages.some((p) => p.items.some((r) => r.status === "running" || r.status === "queued"))
|
||||
? 5 * TTL_SECOND
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecord(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: recordKeys.one(id ?? ""),
|
||||
enabled: !!id,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(PublicOrganizationRecord, "/api/v1/organization-records/:id", {
|
||||
pathParams: { id: id ?? "" },
|
||||
signal,
|
||||
}),
|
||||
refetchInterval: (q) =>
|
||||
q.state.data && (q.state.data.status === "running" || q.state.data.status === "queued") ? 3 * TTL_SECOND : false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 某作品的整理记录(业务时间线用) */
|
||||
export function useRecordsForEntity(entityId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: recordKeys.entity(entityId ?? ""),
|
||||
enabled: !!entityId,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(RecordPage, "/api/v1/organization-records", {
|
||||
params: { mediaEntityId: entityId ?? "", limit: 100 },
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 手动发起整理;dryRun=true 只生成计划供预览 */
|
||||
export function useOrganize() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: OrganizeRequest) =>
|
||||
apiMutate("/api/v1/organization-records", { body }).then((d) => OrganizeAccepted.parse(d)),
|
||||
onSuccess: (_d, v) => {
|
||||
if (!v.dryRun) qc.invalidateQueries({ queryKey: recordKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const RECORD_STATUS_LABEL: Record<OrganizationStatus, string> = {
|
||||
queued: "排队",
|
||||
running: "执行中",
|
||||
succeeded: "成功",
|
||||
partial: "部分成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning" | "destructive" | "neutral"> = {
|
||||
queued: "neutral",
|
||||
running: "neutral",
|
||||
succeeded: "success",
|
||||
partial: "warning",
|
||||
failed: "destructive",
|
||||
cancelled: "neutral",
|
||||
};
|
||||
|
||||
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "Agent" };
|
||||
|
||||
/** dryRun 生成的整理计划冻结为 artifact(kind=plan);读取端点未提供时返回 not_found,由页面说明 */
|
||||
export function useArtifact(id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["artifact", id ?? ""],
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
queryFn: ({ signal }) => apiParsed(Artifact, "/api/v1/artifacts/:id", { pathParams: { id: id ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** artifact.content.plan.organize 的前端视图(与 domain OrganizePlan 同形,只取展示字段) */
|
||||
export const OrganizePlanPreview = z.object({
|
||||
request: z.record(z.string(), z.unknown()).optional(),
|
||||
status: z.string().optional(),
|
||||
reason: z.string().nullable().optional(),
|
||||
plan: z
|
||||
.object({
|
||||
targetDirs: z.array(z.string()).optional(),
|
||||
organize: z
|
||||
.object({
|
||||
operation: z.string(),
|
||||
crossDevice: z.boolean(),
|
||||
requiresApproval: z.boolean(),
|
||||
files: z.array(
|
||||
z.object({
|
||||
src: z.string(),
|
||||
dst: z.string(),
|
||||
size: z.number(),
|
||||
kind: z.string(),
|
||||
action: z.string(),
|
||||
skipReason: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })),
|
||||
conflicts: z.array(
|
||||
z.object({
|
||||
kind: z.string(),
|
||||
blocking: z.boolean(),
|
||||
src: z.string().optional(),
|
||||
detail: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
export type OrganizePlanPreview = z.infer<typeof OrganizePlanPreview>;
|
||||
|
||||
/** 整理目标库根 */
|
||||
export function useLibraryRoots() {
|
||||
return useQuery({
|
||||
queryKey: ["records", "library-roots"],
|
||||
staleTime: 5 * 60 * TTL_SECOND,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200 }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 选作品:按标题搜本地媒体库 */
|
||||
export function useEntitySearch(q: string) {
|
||||
return useQuery({
|
||||
queryKey: ["records", "entity-search", q],
|
||||
enabled: q.trim().length > 0,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(cursorPage(LibraryItem), "/api/v1/library", { params: { q: q.trim(), limit: 10 }, signal }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AcquisitionPlan, type ResolutionStatus, WorkbenchReleaseDetail, WorkbenchReleasePage } from "@umt/contracts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
|
||||
// 站点工作台整理视图(W2-B:/sites/{id}/releases、/sites/{id}/releases/{rid})
|
||||
|
||||
export type ReleaseFilters = {
|
||||
q?: string;
|
||||
category?: string;
|
||||
promotion?: string;
|
||||
resolution?: ResolutionStatus;
|
||||
};
|
||||
|
||||
export const releaseKeys = {
|
||||
all: ["releases"] as const,
|
||||
list: (siteId: string, f: ReleaseFilters, live: boolean) => ["releases", "list", siteId, f, live] as const,
|
||||
detail: (siteId: string, rid: string) => ["releases", "detail", siteId, rid] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一资源列表,游标分页。live=true 强制实时补查(只对第一页);否则让服务端按"本地无结果或快照过期"自行决定。
|
||||
* 翻页时保留上一次数据,避免"提取失败 = 空列表"。
|
||||
*/
|
||||
export function useSiteReleases(siteId: string | undefined, filters: ReleaseFilters, live = false) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: releaseKeys.list(siteId ?? "", filters, live),
|
||||
enabled: !!siteId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
apiParsed(WorkbenchReleasePage, "/api/v1/sites/:id/releases", {
|
||||
pathParams: { id: siteId ?? "" },
|
||||
params: cleanParams({
|
||||
...filters,
|
||||
cursor: pageParam,
|
||||
limit: 50,
|
||||
live: live && !pageParam ? "true" : undefined,
|
||||
}),
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 资源详情;refresh=true 时实时复核(失败保留既有数据并带 refreshError) */
|
||||
export function useReleaseDetail(siteId: string | undefined, rid: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: releaseKeys.detail(siteId ?? "", rid ?? ""),
|
||||
enabled: !!siteId && !!rid,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(WorkbenchReleaseDetail, "/api/v1/sites/:id/releases/:rid", {
|
||||
pathParams: { id: siteId ?? "", rid: rid ?? "" },
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRefreshReleaseDetail() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ siteId, rid }: { siteId: string; rid: string }) =>
|
||||
apiParsed(WorkbenchReleaseDetail, "/api/v1/sites/:id/releases/:rid", {
|
||||
pathParams: { id: siteId, rid },
|
||||
params: { refresh: "true" },
|
||||
}),
|
||||
onSuccess: (d, v) => qc.setQueryData(releaseKeys.detail(v.siteId, v.rid), d),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一下载入口:与自动收录同一条提交流程(计划 → 去重 → 缓存硬阈值 → 实例选择 → 管理标签)。
|
||||
* 后端对应端点 POST /acquisition-plans(sourceReleaseId + 可选 slotId);未提供时返回 not_found,页面给出说明。
|
||||
*/
|
||||
export function useSubmitRelease() {
|
||||
return useMutation({
|
||||
mutationFn: (body: { sourceReleaseId: string; slotId?: string; note?: string }) =>
|
||||
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => AcquisitionPlan.parse(d)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { cursorPage, Id, Site, SiteAccount, SiteDefinitionView, SiteSnapshot } from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiMutate, apiParsed } from "./parse.ts";
|
||||
|
||||
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
|
||||
|
||||
const SitePage = cursorPage(Site);
|
||||
const AccountPage = cursorPage(SiteAccount);
|
||||
const SnapshotPage = cursorPage(SiteSnapshot);
|
||||
|
||||
export const siteKeys = {
|
||||
all: ["sites"] as const,
|
||||
list: () => ["sites", "list"] as const,
|
||||
one: (id: string) => ["sites", "one", id] as const,
|
||||
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
|
||||
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
|
||||
definitions: () => ["sites", "definitions"] as const,
|
||||
};
|
||||
|
||||
export function useSites() {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.list(),
|
||||
queryFn: ({ signal }) => apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200 }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSite(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.one(id ?? ""),
|
||||
enabled: !!id && Id.safeParse(id).success,
|
||||
queryFn: ({ signal }) => apiParsed(Site, "/api/v1/sites/:id", { pathParams: { id: id ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSiteAccounts(siteId?: string) {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.accounts(siteId),
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSiteDefinitions() {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.definitions(),
|
||||
staleTime: 5 * 60 * TTL_SECOND,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(z.object({ items: z.array(SiteDefinitionView) }), "/api/v1/site-definitions", { signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 快照/补查记录;有 running 时 5 秒轮询 */
|
||||
export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.snapshots(siteId ?? ""),
|
||||
enabled: !!siteId,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(SnapshotPage, "/api/v1/sites/:id/snapshots", {
|
||||
pathParams: { id: siteId ?? "" },
|
||||
params: { limit },
|
||||
signal,
|
||||
}),
|
||||
refetchInterval: (q) => (q.state.data?.items.some((s) => s.status === "running") ? 5 * TTL_SECOND : false),
|
||||
});
|
||||
}
|
||||
|
||||
/** 启用 / 停用站点 */
|
||||
export function useToggleSite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||
apiMutate(`/api/v1/sites/:id/${enabled ? "enable" : "disable"}`, { pathParams: { id } }).then((d) =>
|
||||
Site.parse(d),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 手动快照(受站点限流),返回任务 ID */
|
||||
export function useSnapshotSite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, maxPages }: { id: string; maxPages?: number }) =>
|
||||
apiMutate(`/api/v1/sites/:id/snapshot`, { pathParams: { id }, body: maxPages ? { maxPages } : {} }).then((d) =>
|
||||
z.object({ taskId: Id }).parse(d),
|
||||
),
|
||||
onSuccess: (_d, v) => {
|
||||
qc.invalidateQueries({ queryKey: siteKeys.snapshots(v.id) });
|
||||
qc.invalidateQueries({ queryKey: siteKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 快照新鲜度:从未快照或超过 2 个周期视为过期(与服务端 WorkbenchCoverage.stale 同口径) */
|
||||
export function snapshotStale(site: Pick<Site, "lastSnapshotAt" | "snapshotIntervalSec">, now = Date.now()) {
|
||||
if (!site.lastSnapshotAt) return true;
|
||||
const interval = (site.snapshotIntervalSec ?? 3600) * 1000;
|
||||
return now - new Date(site.lastSnapshotAt).getTime() > interval * 2;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
type AcquisitionPlan,
|
||||
cursorPage,
|
||||
Decision,
|
||||
type DownloadTask,
|
||||
EngineRun,
|
||||
EngineRunDetail,
|
||||
type PublicOrganizationRecord,
|
||||
SourceRelease,
|
||||
} from "@umt/contracts";
|
||||
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
||||
import { apiParsed, cleanParams } from "./parse.ts";
|
||||
import { useRecordsForEntity } from "./records.ts";
|
||||
|
||||
// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
||||
// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。
|
||||
|
||||
const EnginePage = cursorPage(EngineRun);
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎 run 关联:submitDownload 的 input 带 acquisitionPlanId,verifyAndImport 的 input 带 downloadTaskId。
|
||||
* 服务端没有"按业务对象查 run"的端点,这里在时间窗内取少量候选 run 读详情后按 input 精确匹配。
|
||||
*/
|
||||
export function useTraceEngineRuns(opts: { planId?: string | null; downloadTaskId?: string; since?: string }) {
|
||||
const { planId, downloadTaskId, since } = opts;
|
||||
return useQuery({
|
||||
queryKey: ["engine", "trace", planId ?? "", downloadTaskId ?? "", since ?? ""],
|
||||
enabled: !!since && (!!planId || !!downloadTaskId),
|
||||
staleTime: 60_000,
|
||||
queryFn: async ({ signal }) => {
|
||||
const from = since ? new Date(new Date(since).getTime() - 10 * 60_000).toISOString() : undefined;
|
||||
const lists = await Promise.all(
|
||||
["submitDownload", "verifyAndImport"].map((workflowName) =>
|
||||
apiParsed(EnginePage, "/api/v1/engine/runs", {
|
||||
params: cleanParams({ workflowName, from, limit: 15 }),
|
||||
signal,
|
||||
}).catch(() => ({ items: [], nextCursor: null })),
|
||||
),
|
||||
);
|
||||
const candidates = lists.flatMap((l) => l.items);
|
||||
const details = await Promise.all(
|
||||
candidates.map((r) =>
|
||||
apiParsed(EngineRunDetail, "/api/v1/engine/runs/:id", { pathParams: { id: r.id }, signal }).catch(() => null),
|
||||
),
|
||||
);
|
||||
return details.filter((d): d is EngineRunDetail => {
|
||||
if (!d) return false;
|
||||
const input = (d.input ?? {}) as Record<string, unknown>;
|
||||
return (
|
||||
(!!planId && input.acquisitionPlanId === planId) ||
|
||||
(!!downloadTaskId && input.downloadTaskId === downloadTaskId)
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
|
||||
queued: "neutral",
|
||||
running: "neutral",
|
||||
succeeded: "success",
|
||||
partial: "warning",
|
||||
failed: "destructive",
|
||||
cancelled: "neutral",
|
||||
};
|
||||
|
||||
/** 整理记录与下载的关联:同实例 + 源路径落在下载内容路径下(合同里没有 downloadTaskId,只能按路径匹配) */
|
||||
export function recordsForDownload(records: PublicOrganizationRecord[], task: DownloadTask) {
|
||||
const base = (task.contentPath ?? task.savePath ?? "").replace(/\/+$/, "");
|
||||
return records.filter(
|
||||
(r) =>
|
||||
r.source.downloaderInstanceId === task.instanceId &&
|
||||
!!base &&
|
||||
!!r.source.path &&
|
||||
(r.source.path === base || r.source.path.startsWith(`${base}/`)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 汇总一个下载 trace 的业务事件,按时间升序 */
|
||||
export function buildTimeline(input: {
|
||||
task: DownloadTask;
|
||||
release?: SourceRelease | null;
|
||||
plan?: AcquisitionPlan | null;
|
||||
records?: PublicOrganizationRecord[];
|
||||
decisions?: Decision[];
|
||||
engineRuns?: EngineRunDetail[];
|
||||
}): TimelineEvent[] {
|
||||
const { task, release, plan, records = [], decisions = [], engineRuns = [] } = 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" ? "Copy" : r.action === "move" ? "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 r of engineRuns) {
|
||||
ev.push({
|
||||
key: `eng-${r.id}`,
|
||||
stage: "engine",
|
||||
at: r.startedAt ?? r.createdAt,
|
||||
title: `引擎 ${r.workflowName}:${r.status}`,
|
||||
detail: `${r.stepAttempts.length} 个步骤尝试 · 第 ${r.attempts} 次`,
|
||||
tone:
|
||||
r.status === "failed"
|
||||
? "destructive"
|
||||
: r.status === "completed" || r.status === "succeeded"
|
||||
? "success"
|
||||
: "neutral",
|
||||
href: `/console/engine/${encodeURIComponent(r.id)}`,
|
||||
ongoing: r.status === "running" || r.status === "pending" || r.status === "sleeping",
|
||||
});
|
||||
}
|
||||
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 entityId = release.data?.mediaEntityId ?? null;
|
||||
const records = useRecordsForEntity(entityId);
|
||||
const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]);
|
||||
const engine = useTraceEngineRuns({
|
||||
planId: task?.acquisitionPlanId,
|
||||
downloadTaskId: task?.id,
|
||||
since: plan?.createdAt ?? task?.createdAt,
|
||||
});
|
||||
const matched = task ? recordsForDownload(records.data?.items ?? [], task) : [];
|
||||
return {
|
||||
release,
|
||||
records,
|
||||
decisions,
|
||||
engine,
|
||||
events: task
|
||||
? buildTimeline({
|
||||
task,
|
||||
release: release.data,
|
||||
plan,
|
||||
records: matched,
|
||||
decisions: decisions.data,
|
||||
engineRuns: engine.data,
|
||||
})
|
||||
: [],
|
||||
loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user