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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
Layers,
|
||||
LoaderCircle,
|
||||
ShieldAlert,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
type AgentRunView,
|
||||
type GoalReport,
|
||||
isRunActive,
|
||||
REPORT_STATE,
|
||||
RUN_STATUS_LABEL,
|
||||
RUN_STATUS_TONE,
|
||||
} from "~/api/agent.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
const BUDGET_LABEL: Record<string, string> = {
|
||||
tool_calls: "工具调用次数",
|
||||
duration: "运行时长",
|
||||
tokens: "token",
|
||||
cost: "费用",
|
||||
};
|
||||
const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" };
|
||||
|
||||
function durationText(a: string, b: string | null) {
|
||||
if (!b) return null;
|
||||
const ms = new Date(b).getTime() - new Date(a).getTime();
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
/** 摘要块:长文本在块内滚动,不撑坏布局 */
|
||||
function Summary({ label, text, tone }: { label: string; text: string; tone?: "destructive" }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-48 overflow-auto rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 font-mono text-meta break-all whitespace-pre-wrap text-foreground",
|
||||
tone === "destructive" && "border-destructive/30 text-destructive",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_STATUS: Record<
|
||||
ToolCallView["status"],
|
||||
{ label: string; tone: "success" | "destructive" | "warning" | "neutral" }
|
||||
> = {
|
||||
running: { label: "执行中", tone: "neutral" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
denied: { label: "已拒绝", tone: "destructive" },
|
||||
};
|
||||
|
||||
/** 工具调用卡片:默认折叠,展开看脱敏输入/输出 */
|
||||
export function ToolCallCard({ call }: { call: ToolCallView }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const st = TOOL_STATUS[call.status];
|
||||
if (call.status === "denied") {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-destructive">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
<span>服务端拒绝调用</span>
|
||||
<code className="font-mono text-meta break-all">{call.capabilityId}</code>
|
||||
</div>
|
||||
<p className="mt-1 text-caption break-words text-destructive">{call.reason ?? "未授权"}</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">授权校验在服务端完成;Agent 没有绕过的途径。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2;
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-border/70">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-caption text-foreground">{call.capabilityId}</code>
|
||||
{call.recovery ? <StatusBadge>恢复补记</StatusBadge> : null}
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
{durationText(call.startedAt, call.endedAt) ?? ""}
|
||||
</span>
|
||||
<StatusBadge tone={st.tone}>
|
||||
<Icon className={cn("h-3 w-3", call.status === "running" && "animate-spin")} />
|
||||
{st.label}
|
||||
</StatusBadge>
|
||||
</button>
|
||||
{!open && call.outputSummary ? (
|
||||
<p className="truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{call.outputSummary}</p>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
|
||||
{call.inputSummary ? <Summary label="输入(已脱敏)" text={call.inputSummary} /> : null}
|
||||
{call.outputSummary ? (
|
||||
<Summary
|
||||
label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
|
||||
text={call.outputSummary}
|
||||
tone={call.status === "failed" ? "destructive" : undefined}
|
||||
/>
|
||||
) : call.status === "running" ? (
|
||||
<p className="text-meta text-muted-foreground">等待结果…</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamBlock({ item }: { item: StreamItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
switch (item.kind) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-sm leading-relaxed break-words whitespace-pre-wrap text-foreground">
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className={cn("h-3 w-3 transition-transform duration-base", open && "rotate-90")} />
|
||||
思考过程
|
||||
</button>
|
||||
{open ? (
|
||||
<p className="mt-1 max-h-48 overflow-auto text-caption break-words whitespace-pre-wrap text-muted-foreground">
|
||||
{item.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
case "tool":
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<ToolCallCard call={item.call} />
|
||||
</div>
|
||||
);
|
||||
case "compaction":
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
上下文压缩({COMPACT_REASON[item.reason] ?? item.reason})
|
||||
{item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "end":
|
||||
if (item.ok) return null;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"ml-8 rounded-xl border px-3 py-2.5 text-caption",
|
||||
item.reason === "budget"
|
||||
? "border-warning/40 bg-warning/10 text-warning"
|
||||
: item.reason === "aborted"
|
||||
? "border-border bg-foreground/[0.04] text-muted-foreground"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">
|
||||
{item.reason === "budget"
|
||||
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""}`
|
||||
: item.reason === "aborted"
|
||||
? "已中止"
|
||||
: "运行失败"}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words">{item.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Meter({
|
||||
label,
|
||||
used,
|
||||
max,
|
||||
format,
|
||||
}: {
|
||||
label: string;
|
||||
used: number | undefined;
|
||||
max?: number;
|
||||
format: (n: number) => string;
|
||||
}) {
|
||||
const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 text-meta">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-foreground tabular-nums">
|
||||
{used === undefined ? "未报告" : format(used)}
|
||||
{max ? <span className="text-muted-foreground"> / {format(max)}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
|
||||
{pct !== null ? (
|
||||
<div
|
||||
className={cn("h-full rounded-full", pct >= 90 ? "bg-warning" : "bg-foreground/60")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */
|
||||
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
|
||||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||
const durationMs =
|
||||
u.durationMs ??
|
||||
(run.startedAt
|
||||
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
|
||||
: undefined);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
|
||||
<Meter label="工具调用" used={toolCalls} max={run.budget.maxToolCalls} format={(n) => String(n)} />
|
||||
<Meter
|
||||
label="token"
|
||||
used={u.totalTokens}
|
||||
max={run.budget.maxTokens}
|
||||
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
|
||||
/>
|
||||
<Meter
|
||||
label="时长"
|
||||
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
||||
max={run.budget.maxDurationSec}
|
||||
format={(n) => `${n}s`}
|
||||
/>
|
||||
<Meter label="费用(估算)" used={u.costUsd} max={run.budget.maxCostUsd} format={(n) => `$${n.toFixed(3)}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最终汇报:模型说的 vs 系统核实的(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
|
||||
export function FinalReport({
|
||||
run,
|
||||
report,
|
||||
live,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
report: GoalReport | null;
|
||||
live: AgentStreamState;
|
||||
}) {
|
||||
const lastTool = live.items.map((i) => i.kind).lastIndexOf("tool");
|
||||
const tail = live.items
|
||||
.slice(lastTool + 1)
|
||||
.filter((i): i is Extract<StreamItem, { kind: "text" }> => i.kind === "text")
|
||||
.map((i) => i.text)
|
||||
.join("");
|
||||
const said = report?.modelText ?? (tail || run.summary || "");
|
||||
const calls = live.items
|
||||
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
|
||||
.map((i) => i.call);
|
||||
const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length;
|
||||
const state = report ? REPORT_STATE[report.state] : null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
模型说的
|
||||
</p>
|
||||
<p className="max-h-60 overflow-auto text-sm break-words whitespace-pre-wrap text-foreground">
|
||||
{said || "(没有文字汇报)"}
|
||||
</p>
|
||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
系统核实的结果
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
||||
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
|
||||
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
|
||||
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
|
||||
{count("denied") ? <StatusBadge tone="destructive">被拒绝 {count("denied")}</StatusBadge> : null}
|
||||
</div>
|
||||
{report ? (
|
||||
<>
|
||||
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
|
||||
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
|
||||
{(
|
||||
[
|
||||
["已提交", report.submitted],
|
||||
["已入库", report.imported],
|
||||
["进行中", report.inProgress],
|
||||
["失败", report.failed],
|
||||
] as const
|
||||
).map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg border border-border/60 px-1 py-1.5">
|
||||
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
|
||||
<dt className="text-meta text-muted-foreground">{k}</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<ul className="mt-2 space-y-1 text-caption">
|
||||
{report.downloads.map((d) => (
|
||||
<li key={d.downloadTaskId} className="min-w-0 truncate">
|
||||
<Link
|
||||
to={`/console/tasks/${d.downloadTaskId}`}
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
下载 · {d.name ?? d.downloadTaskId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · {d.status}</span>
|
||||
</li>
|
||||
))}
|
||||
{report.organization.map((o) => (
|
||||
<li key={o.recordId} className="min-w-0 truncate">
|
||||
<Link
|
||||
to={`/console/organize/${o.recordId}`}
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · {o.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-meta text-muted-foreground">服务端汇报尚未生成;以任务页、整理记录的实际状态为准。</p>
|
||||
)}
|
||||
{run.error ? (
|
||||
<p className="mt-2 text-caption break-words text-destructive">
|
||||
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
|
||||
export function AgentConversation({
|
||||
run,
|
||||
live,
|
||||
userMessages = [],
|
||||
compact = false,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
live: AgentStreamState;
|
||||
userMessages?: { at: string; text: string; pending?: boolean }[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const active = isRunActive(run.status) && !live.finished;
|
||||
const count = live.items.length;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
|
||||
useEffect(() => {
|
||||
if (active) endRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [count, active]);
|
||||
|
||||
// 追加的用户消息按时间插入
|
||||
type Row = { key: string; at: string; node: ReactNode };
|
||||
const rows: Row[] = live.items.map((it) => ({
|
||||
key: it.key,
|
||||
at: it.kind === "tool" ? it.call.startedAt : it.at,
|
||||
node: <StreamBlock item={it} />,
|
||||
}));
|
||||
for (const m of userMessages)
|
||||
rows.push({
|
||||
key: `u${m.at}`,
|
||||
at: m.at,
|
||||
node: <UserBubble text={m.text} note={m.pending ? "等待 Agent 读取" : undefined} />,
|
||||
});
|
||||
rows.sort((a, b) => a.at.localeCompare(b.at));
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", compact && "text-sm")}>
|
||||
<UserBubble text={run.goal} mode={run.mode ?? (run.scope.mode as string | undefined)} />
|
||||
{rows.map((r) => (
|
||||
<div key={r.key}>{r.node}</div>
|
||||
))}
|
||||
{active ? (
|
||||
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
|
||||
<CircleDashed className="h-3.5 w-3.5 animate-spin" />
|
||||
{run.status === "waiting" ? "等待中…" : "正在处理…"}
|
||||
</div>
|
||||
) : null}
|
||||
{live.error ? <p className="pl-8 text-meta text-destructive">事件读取失败:{live.error}(稍后自动重试)</p> : null}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<div className="max-w-[85%] min-w-0 rounded-2xl border border-border/70 bg-foreground/[0.05] px-3 py-2">
|
||||
{mode === "plan_only" ? <p className="mb-0.5 text-meta text-muted-foreground">只要方案</p> : null}
|
||||
<p className="text-sm break-words whitespace-pre-wrap text-foreground">{text}</p>
|
||||
{note ? <p className="mt-0.5 text-meta text-muted-foreground">{note}</p> : null}
|
||||
</div>
|
||||
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
|
||||
return (
|
||||
<StatusBadge tone={RUN_STATUS_TONE[status]}>
|
||||
{isRunActive(status) ? <LoaderCircle className="h-3 w-3 animate-spin" /> : null}
|
||||
{RUN_STATUS_LABEL[status]}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Bot, ExternalLink, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||
import {
|
||||
contextPrefix,
|
||||
openAgentDrawer,
|
||||
setAgentDrawerOpen,
|
||||
setAgentDrawerRun,
|
||||
useAgentDrawer,
|
||||
} from "./agent-store.ts";
|
||||
|
||||
/**
|
||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。
|
||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
||||
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
||||
*/
|
||||
export function AgentDrawer() {
|
||||
const { open, context, runId } = useAgentDrawer();
|
||||
const create = useCreateAgentRun();
|
||||
const prefix = contextPrefix(context);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-lg">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription className="break-words">
|
||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{runId ? (
|
||||
<AgentRunPanel runId={runId} compact />
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
compact
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, mode },
|
||||
{
|
||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
{runId ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||
<Plus />
|
||||
新目标
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在会话页打开
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */
|
||||
export function AgentDrawerTrigger() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="打开 Agent" title="Agent" onClick={() => openAgentDrawer(null)}>
|
||||
<Bot />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Send, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentMode,
|
||||
agentMockEnabled,
|
||||
isRunActive,
|
||||
useAbortAgentRun,
|
||||
useAgentRun,
|
||||
useAppendAgentMessage,
|
||||
} from "~/api/agent.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
|
||||
|
||||
/** 目标输入:execute / plan_only 分段切换 + 文本框 */
|
||||
export function GoalComposer({
|
||||
onSubmit,
|
||||
pending,
|
||||
prefix,
|
||||
defaultGoal = "",
|
||||
compact = false,
|
||||
}: {
|
||||
onSubmit: (goal: string, mode: AgentMode) => void;
|
||||
pending: boolean;
|
||||
/** 上下文前缀(只读展示) */
|
||||
prefix?: string;
|
||||
defaultGoal?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [goal, setGoal] = useState(defaultGoal);
|
||||
const [mode, setMode] = useState<AgentMode>("execute");
|
||||
const submit = () => {
|
||||
const g = goal.trim();
|
||||
if (!g) {
|
||||
toast.error("先写下目标");
|
||||
return;
|
||||
}
|
||||
onSubmit(g, mode);
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{prefix ? (
|
||||
<p
|
||||
className="truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
|
||||
title={prefix}
|
||||
>
|
||||
{prefix}
|
||||
</p>
|
||||
) : null}
|
||||
<Textarea
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
|
||||
rows={compact ? 3 : 3}
|
||||
maxLength={4000}
|
||||
aria-label="目标"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<fieldset aria-label="运行模式" className="flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
["execute", "授权内执行"],
|
||||
["plan_only", "只要方案"],
|
||||
] as const
|
||||
).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={mode === v}
|
||||
onClick={() => setMode(v)}
|
||||
className={cn(
|
||||
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground transition-colors duration-micro hover:text-foreground",
|
||||
mode === v && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
<Send />
|
||||
{pending ? "提交中…" : "开始"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{mode === "plan_only"
|
||||
? "只调查、出方案,不执行任何写操作。"
|
||||
: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。"}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次运行:头部状态 + 预算条 + 流式对话 + 最终汇报 + 追加消息 */
|
||||
export function AgentRunPanel({ runId, compact = false }: { runId: string; compact?: boolean }) {
|
||||
const run = useAgentRun(runId);
|
||||
const active = isRunActive(run.data?.run.status);
|
||||
const live = useAgentStream(runId, active || !run.data);
|
||||
const abort = useAbortAgentRun();
|
||||
const append = useAppendAgentMessage();
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
if (run.isPending) return <LoadingState rows={4} />;
|
||||
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
|
||||
const { run: r, report, messages } = run.data;
|
||||
const finished = !isRunActive(r.status) || live.finished;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RunStatusBadge status={live.finished && isRunActive(r.status) ? "running" : r.status} />
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
</span>
|
||||
{agentMockEnabled() ? <StatusBadge tone="warning">mock 数据</StatusBadge> : null}
|
||||
{active ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" className="ml-auto" disabled={abort.isPending}>
|
||||
<Square />
|
||||
中止
|
||||
</Button>
|
||||
}
|
||||
title="中止这次运行?"
|
||||
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
|
||||
confirmLabel="中止"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
abort.mutateAsync(r.id).then(
|
||||
() => toast.success("已请求中止"),
|
||||
(e) => toast.error(errorMessage(e)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
|
||||
<AgentConversation
|
||||
run={r}
|
||||
live={live}
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
compact={compact}
|
||||
/>
|
||||
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const text = msg.trim();
|
||||
if (!text) return;
|
||||
append.mutate(
|
||||
{ id: r.id, text },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMsg("");
|
||||
void run.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
rows={1}
|
||||
maxLength={8000}
|
||||
placeholder={active ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"}
|
||||
aria-label="追加消息"
|
||||
className="min-h-9 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={append.isPending || !msg.trim()}
|
||||
aria-label="发送"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
|
||||
|
||||
export type AgentContext = {
|
||||
/** 显示给用户的上下文说明,如"作品:沙丘2 (2024)" */
|
||||
label?: string;
|
||||
entityId?: string;
|
||||
releaseId?: string;
|
||||
downloadTaskId?: string;
|
||||
recordId?: string;
|
||||
siteId?: string;
|
||||
/** 预填目标 */
|
||||
goal?: string;
|
||||
};
|
||||
|
||||
type State = { open: boolean; context: AgentContext | null; runId: string | null };
|
||||
|
||||
let state: State = { open: false, context: null, runId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = () => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
export function openAgentDrawer(context: AgentContext | null = null) {
|
||||
state = { open: true, context, runId: null };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setAgentDrawerOpen(open: boolean) {
|
||||
state = { ...state, open };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setAgentDrawerRun(runId: string | null) {
|
||||
state = { ...state, runId };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function useAgentDrawer(): State {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => state,
|
||||
() => state,
|
||||
);
|
||||
}
|
||||
|
||||
/** 上下文 → goal 前缀(服务端按 scope.context 解析对象;前缀让模型和人都看得到指代) */
|
||||
export function contextPrefix(ctx: AgentContext | null): string {
|
||||
if (!ctx) return "";
|
||||
const parts: string[] = [];
|
||||
if (ctx.label) parts.push(ctx.label);
|
||||
if (ctx.entityId) parts.push(`entityId=${ctx.entityId}`);
|
||||
if (ctx.releaseId) parts.push(`sourceReleaseId=${ctx.releaseId}`);
|
||||
if (ctx.downloadTaskId) parts.push(`downloadTaskId=${ctx.downloadTaskId}`);
|
||||
if (ctx.recordId) parts.push(`organizationRecordId=${ctx.recordId}`);
|
||||
if (ctx.siteId) parts.push(`siteId=${ctx.siteId}`);
|
||||
return parts.length ? `[上下文 ${parts.join(" · ")}] ` : "";
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
|
||||
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
|
||||
|
||||
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
|
||||
|
||||
type Step = { delayMs: number; body: AgentRunEventBody };
|
||||
type Entry = {
|
||||
run: AgentRunView;
|
||||
steps: Step[];
|
||||
startedAt: number;
|
||||
abortedAt: number | null;
|
||||
appended: RunMessage[];
|
||||
};
|
||||
|
||||
const store = new Map<string, Entry>();
|
||||
let seeded = false;
|
||||
|
||||
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
const USAGE: AgentUsageTotals = {
|
||||
inputTokens: 18_420,
|
||||
outputTokens: 1_236,
|
||||
cacheReadTokens: 12_800,
|
||||
cacheWriteTokens: 2_048,
|
||||
totalTokens: 19_656,
|
||||
costUsd: 0.042,
|
||||
};
|
||||
|
||||
function chunks(text: string, size = 6): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function script(goal: string, mode: AgentMode): Step[] {
|
||||
const s: Step[] = [];
|
||||
let t = 0;
|
||||
const push = (dt: number, body: AgentRunEventBody) => {
|
||||
t += dt;
|
||||
s.push({ delayMs: t, body });
|
||||
};
|
||||
const say = (text: string) => {
|
||||
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
|
||||
};
|
||||
push(0, { type: "run_started", goal, resumed: false });
|
||||
say("先确认本地库存与版本目标,再去站点找候选。");
|
||||
push(400, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t1",
|
||||
capabilityId: "inventory.query",
|
||||
inputSummary: '{"query":"沙丘:第二部","year":2024}',
|
||||
recovery: false,
|
||||
});
|
||||
push(900, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t1",
|
||||
capabilityId: "inventory.query",
|
||||
isError: false,
|
||||
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t2",
|
||||
capabilityId: "release.search",
|
||||
inputSummary: '{"site":"audiences","q":"Dune Part Two 2160p","passkey":"[REDACTED]"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(1200, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t2",
|
||||
capabilityId: "release.search",
|
||||
isError: false,
|
||||
outputSummary:
|
||||
"3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, { type: "compaction", phase: "start", reason: "threshold" });
|
||||
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
|
||||
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
|
||||
if (mode === "execute") {
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(800, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
isError: false,
|
||||
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_rejected",
|
||||
toolCallId: "t4",
|
||||
toolName: "qb_delete_torrent",
|
||||
capabilityId: "download.cleanup",
|
||||
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
inputSummary: '{"planId":"…9a2e"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(1000, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
isError: false,
|
||||
outputSummary: "已提交到 qb-main,标签 umt-managed,任务 ID …41d0",
|
||||
recovery: false,
|
||||
});
|
||||
say("已提交 4K REMUX 下载。完成后会自动核验并 Copy 入库,原 1080p 保留。");
|
||||
} else {
|
||||
say("只要方案:建议提交①,预计占用缓存 78.4 GB,完成后 Copy 入库、保留 1080p。未执行任何写操作。");
|
||||
}
|
||||
push(200, { type: "run_completed", finalText: "", usage: USAGE });
|
||||
return s;
|
||||
}
|
||||
|
||||
function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, status: AgentRunView["status"]) {
|
||||
return {
|
||||
id,
|
||||
createdAt: iso(createdAt),
|
||||
updatedAt: iso(createdAt),
|
||||
taskId: null,
|
||||
parentRunId: null,
|
||||
trigger: "user",
|
||||
goal,
|
||||
status,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
thinkingLevel: "medium",
|
||||
scope: { mode },
|
||||
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
|
||||
usage: {},
|
||||
evidenceVersion: null,
|
||||
sessionState: null,
|
||||
summary: null,
|
||||
toolCallCount: 0,
|
||||
lastEventSeq: 0,
|
||||
error: null,
|
||||
cancelRequestedAt: null,
|
||||
startedAt: iso(createdAt),
|
||||
finishedAt: null,
|
||||
createdBy: "admin",
|
||||
mode,
|
||||
dedupeKey: null,
|
||||
} satisfies AgentRunView;
|
||||
}
|
||||
|
||||
function add(goal: string, mode: AgentMode, startedAt: number, n: number): Entry {
|
||||
const id = uuid(n);
|
||||
const e: Entry = {
|
||||
run: baseRun(id, goal, mode, startedAt, "running"),
|
||||
steps: script(goal, mode),
|
||||
startedAt,
|
||||
abortedAt: null,
|
||||
appended: [],
|
||||
};
|
||||
store.set(id, e);
|
||||
return e;
|
||||
}
|
||||
|
||||
function seed() {
|
||||
if (seeded) return;
|
||||
seeded = true;
|
||||
const now = Date.now();
|
||||
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
|
||||
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
|
||||
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
|
||||
failed.steps = [
|
||||
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
|
||||
{
|
||||
delayMs: 100,
|
||||
body: {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "x1",
|
||||
capabilityId: "task.inspect",
|
||||
inputSummary: '{"instance":"qb-main"}',
|
||||
recovery: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
delayMs: 200,
|
||||
body: {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "x1",
|
||||
capabilityId: "task.inspect",
|
||||
isError: true,
|
||||
outputSummary: "下载器连接超时(qb-main 10.0.0.12:8080)",
|
||||
recovery: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
delayMs: 300,
|
||||
body: {
|
||||
type: "run_failed",
|
||||
reason: "budget",
|
||||
budget: "duration",
|
||||
message: "超出单次运行时长预算(600 秒)",
|
||||
usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function revealed(e: Entry, now = Date.now()): AgentRunEvent[] {
|
||||
const cut = e.abortedAt ?? now;
|
||||
const out: AgentRunEvent[] = [];
|
||||
let seq = 0;
|
||||
for (const st of e.steps) {
|
||||
if (e.startedAt + st.delayMs > cut) break;
|
||||
out.push({ ...st.body, runId: e.run.id, seq: seq++, at: iso(e.startedAt + st.delayMs) } as AgentRunEvent);
|
||||
}
|
||||
if (e.abortedAt && !out.some((x) => x.type === "run_completed" || x.type === "run_failed")) {
|
||||
out.push({
|
||||
type: "run_failed",
|
||||
reason: "aborted",
|
||||
message: "用户中止",
|
||||
usage: USAGE,
|
||||
runId: e.run.id,
|
||||
seq: seq++,
|
||||
at: iso(e.abortedAt),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function view(e: Entry): AgentRunView {
|
||||
const evs = revealed(e);
|
||||
const end = evs.find((x) => x.type === "run_completed" || x.type === "run_failed");
|
||||
const tools = evs.filter((x) => x.type === "tool_call_start").length;
|
||||
const text = evs
|
||||
.filter((x) => x.type === "text_delta")
|
||||
.map((x) => (x.type === "text_delta" ? x.delta : ""))
|
||||
.join("");
|
||||
const status: AgentRunView["status"] = !end
|
||||
? "running"
|
||||
: end.type === "run_completed"
|
||||
? "succeeded"
|
||||
: end.reason === "budget"
|
||||
? "budget_exceeded"
|
||||
: end.reason === "aborted"
|
||||
? "cancelled"
|
||||
: "failed";
|
||||
const frac = end ? 1 : Math.min(1, evs.length / Math.max(1, e.steps.length));
|
||||
return {
|
||||
...e.run,
|
||||
status,
|
||||
toolCallCount: tools,
|
||||
lastEventSeq: evs.length ? evs[evs.length - 1]!.seq : 0,
|
||||
usage: end
|
||||
? { ...end.usage, durationMs: new Date(end.at).getTime() - e.startedAt }
|
||||
: {
|
||||
inputTokens: Math.round(USAGE.inputTokens * frac),
|
||||
outputTokens: Math.round(USAGE.outputTokens * frac),
|
||||
totalTokens: Math.round(USAGE.totalTokens * frac),
|
||||
costUsd: +(USAGE.costUsd * frac).toFixed(4),
|
||||
durationMs: Date.now() - e.startedAt,
|
||||
},
|
||||
summary: end ? (end.type === "run_failed" ? end.message : text) : null,
|
||||
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
|
||||
finishedAt: end ? end.at : null,
|
||||
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function MOCK_RUNS(): AgentRunView[] {
|
||||
seed();
|
||||
return [...store.values()].map(view).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function mockDetail(id: string, op?: { abort?: boolean; append?: string }): RunSnapshot {
|
||||
seed();
|
||||
const e = store.get(id);
|
||||
if (!e) throw Object.assign(new Error("会话不存在(mock)"), { code: "not_found" });
|
||||
if (op?.abort && !e.abortedAt) e.abortedAt = Date.now();
|
||||
if (op?.append) {
|
||||
// 追加消息:模拟 Agent 回一句确认
|
||||
const at = Date.now() - e.startedAt;
|
||||
e.appended.push({
|
||||
id: uuid(9000 + e.appended.length),
|
||||
createdAt: iso(Date.now()),
|
||||
text: op.append,
|
||||
status: "pending",
|
||||
consumedAt: null,
|
||||
});
|
||||
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
|
||||
const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({
|
||||
delayMs: at + 200 + i * 60,
|
||||
body: { type: "text_delta", delta: c },
|
||||
}));
|
||||
if (last >= 0 && e.startedAt + e.steps[last]!.delayMs > Date.now()) e.steps.splice(last, 0, ...reply);
|
||||
e.steps.sort((a, b) => a.delayMs - b.delayMs);
|
||||
}
|
||||
const run = view(e);
|
||||
return { run, messages: e.appended, report: report(e, run) };
|
||||
}
|
||||
|
||||
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */
|
||||
function report(e: Entry, run: AgentRunView): GoalReport | null {
|
||||
if (!run.finishedAt) return null;
|
||||
const evs = revealed(e);
|
||||
const submitted = evs.some((x) => x.type === "tool_call_end" && x.capabilityId === "download.submit" && !x.isError);
|
||||
const text = evs
|
||||
.filter((x) => x.type === "text_delta")
|
||||
.map((x) => (x.type === "text_delta" ? x.delta : ""))
|
||||
.join("");
|
||||
return {
|
||||
state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed",
|
||||
summary: submitted
|
||||
? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准"
|
||||
: run.status === "succeeded"
|
||||
? "本次没有产生下载或整理动作"
|
||||
: "运行未完成,没有产生业务动作",
|
||||
submitted: submitted ? 1 : 0,
|
||||
imported: 0,
|
||||
inProgress: submitted ? 1 : 0,
|
||||
failed: 0,
|
||||
plans: submitted ? [{ planId: uuid(7001), status: "submitted" }] : [],
|
||||
downloads: submitted
|
||||
? [{ downloadTaskId: uuid(7002), status: "downloading", name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX" }]
|
||||
: [],
|
||||
organization: [],
|
||||
modelText: text || null,
|
||||
generatedAt: run.finishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
|
||||
seed();
|
||||
const e = store.get(id);
|
||||
if (!e) return [];
|
||||
return revealed(e).filter((x) => x.seq > after);
|
||||
}
|
||||
|
||||
export function mockCreateRun(goal: string, mode: AgentMode): AgentRunView {
|
||||
seed();
|
||||
const e = add(goal, mode, Date.now(), store.size + 1);
|
||||
return view(e);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
||||
import { sse } from "~/lib/sse.ts";
|
||||
|
||||
// Agent 运行流:先按 seq 回放 agent_run_event,再跟随 SSE(agent.* 事件只作"有新事件"的提示,
|
||||
// 收到后按 lastSeq 增量补读;thinking/compaction 不上全局总线,补读时一并拿到)。SSE 不可用时退回轮询。
|
||||
|
||||
export type ToolCallView = {
|
||||
toolCallId: string;
|
||||
capabilityId: string;
|
||||
inputSummary: string | null;
|
||||
outputSummary: string | null;
|
||||
status: "running" | "succeeded" | "failed" | "denied";
|
||||
reason: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
recovery: boolean;
|
||||
};
|
||||
|
||||
export type StreamItem =
|
||||
| { kind: "text"; key: string; text: string; at: string }
|
||||
| { kind: "thinking"; key: string; text: string; at: string }
|
||||
| { kind: "tool"; key: string; call: ToolCallView }
|
||||
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
|
||||
| {
|
||||
kind: "end";
|
||||
key: string;
|
||||
at: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
reason: string | null;
|
||||
budget: string | null;
|
||||
};
|
||||
|
||||
export type AgentStreamState = {
|
||||
events: AgentRunEvent[];
|
||||
items: StreamItem[];
|
||||
usage: AgentUsageTotals | null;
|
||||
finished: boolean;
|
||||
error: string | null;
|
||||
connected: "sse" | "poll" | "mock" | "idle";
|
||||
};
|
||||
|
||||
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
|
||||
|
||||
/** 事件 → 展示项:相邻文本增量合并;工具调用按 toolCallId 折叠成一张卡 */
|
||||
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||
const items: StreamItem[] = [];
|
||||
const tools = new Map<string, ToolCallView>();
|
||||
for (const e of events) {
|
||||
const last = items[items.length - 1];
|
||||
switch (e.type) {
|
||||
case "text_delta":
|
||||
if (last?.kind === "text") last.text += e.delta;
|
||||
else items.push({ kind: "text", key: `t${e.seq}`, text: e.delta, at: e.at });
|
||||
break;
|
||||
case "thinking_delta":
|
||||
if (last?.kind === "thinking") last.text += e.delta;
|
||||
else items.push({ kind: "thinking", key: `th${e.seq}`, text: e.delta, at: e.at });
|
||||
break;
|
||||
case "tool_call_start": {
|
||||
const call: ToolCallView = {
|
||||
toolCallId: e.toolCallId,
|
||||
capabilityId: e.capabilityId,
|
||||
inputSummary: e.inputSummary,
|
||||
outputSummary: null,
|
||||
status: "running",
|
||||
reason: null,
|
||||
startedAt: e.at,
|
||||
endedAt: null,
|
||||
recovery: e.recovery,
|
||||
};
|
||||
tools.set(e.toolCallId, call);
|
||||
items.push({ kind: "tool", key: `c${e.toolCallId}`, call });
|
||||
break;
|
||||
}
|
||||
case "tool_call_end": {
|
||||
const call = tools.get(e.toolCallId);
|
||||
if (call) {
|
||||
call.outputSummary = e.outputSummary;
|
||||
call.status = e.isError ? "failed" : "succeeded";
|
||||
call.endedAt = e.at;
|
||||
} else {
|
||||
items.push({
|
||||
kind: "tool",
|
||||
key: `c${e.toolCallId}`,
|
||||
call: {
|
||||
toolCallId: e.toolCallId,
|
||||
capabilityId: e.capabilityId,
|
||||
inputSummary: null,
|
||||
outputSummary: e.outputSummary,
|
||||
status: e.isError ? "failed" : "succeeded",
|
||||
reason: null,
|
||||
startedAt: e.at,
|
||||
endedAt: e.at,
|
||||
recovery: e.recovery,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_call_rejected": {
|
||||
const call = tools.get(e.toolCallId);
|
||||
if (call) {
|
||||
call.status = "denied";
|
||||
call.reason = e.reason;
|
||||
call.endedAt = e.at;
|
||||
} else {
|
||||
items.push({
|
||||
kind: "tool",
|
||||
key: `c${e.toolCallId}`,
|
||||
call: {
|
||||
toolCallId: e.toolCallId,
|
||||
capabilityId: e.capabilityId ?? e.toolName,
|
||||
inputSummary: null,
|
||||
outputSummary: null,
|
||||
status: "denied",
|
||||
reason: e.reason,
|
||||
startedAt: e.at,
|
||||
endedAt: e.at,
|
||||
recovery: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
if (e.phase === "start")
|
||||
items.push({ kind: "compaction", key: `k${e.seq}`, at: e.at, reason: e.reason, status: null, done: false });
|
||||
else {
|
||||
const open = [...items].reverse().find((i) => i.kind === "compaction" && !i.done);
|
||||
if (open && open.kind === "compaction") {
|
||||
open.done = true;
|
||||
open.status = e.status ?? "completed";
|
||||
} else
|
||||
items.push({
|
||||
kind: "compaction",
|
||||
key: `k${e.seq}`,
|
||||
at: e.at,
|
||||
reason: e.reason,
|
||||
status: e.status ?? "completed",
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "run_completed":
|
||||
items.push({
|
||||
kind: "end",
|
||||
key: `e${e.seq}`,
|
||||
at: e.at,
|
||||
ok: true,
|
||||
message: e.finalText,
|
||||
reason: null,
|
||||
budget: null,
|
||||
});
|
||||
break;
|
||||
case "run_failed":
|
||||
items.push({
|
||||
kind: "end",
|
||||
key: `e${e.seq}`,
|
||||
at: e.at,
|
||||
ok: false,
|
||||
message: e.message,
|
||||
reason: e.reason,
|
||||
budget: e.budget ?? null,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
|
||||
const [events, setEvents] = useState<AgentRunEvent[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
||||
const lastSeq = useRef(-1);
|
||||
const inflight = useRef(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
|
||||
useEffect(() => {
|
||||
setEvents([]);
|
||||
setError(null);
|
||||
lastSeq.current = -1;
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
let stopped = false;
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const pull = async () => {
|
||||
if (inflight.current) {
|
||||
pending.current = true;
|
||||
return;
|
||||
}
|
||||
inflight.current = true;
|
||||
try {
|
||||
do {
|
||||
pending.current = false;
|
||||
const next = await fetchRunEvents(runId, lastSeq.current, ctrl.signal);
|
||||
if (stopped) return;
|
||||
const fresh = next.filter((e) => e.seq > lastSeq.current).sort((a, b) => a.seq - b.seq);
|
||||
if (fresh.length) {
|
||||
lastSeq.current = fresh[fresh.length - 1]!.seq;
|
||||
setEvents((prev) => [...prev, ...fresh]);
|
||||
}
|
||||
setError(null);
|
||||
} while (pending.current && !stopped);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError" && !stopped) setError((err as Error).message);
|
||||
} finally {
|
||||
inflight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void pull();
|
||||
if (!active)
|
||||
return () => {
|
||||
stopped = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
|
||||
const mock = agentMockEnabled();
|
||||
// 共用页面级 SSE 连接:agent.* 事件只当"有新事件"的提示,按 lastSeq 补读
|
||||
const unsubscribe = mock
|
||||
? null
|
||||
: sse.subscribe(SSE_TYPES, (e) => {
|
||||
const p = e.payload as { runId?: string } | null;
|
||||
if (e.type === EventType.resync || p?.runId === runId) void pull();
|
||||
});
|
||||
setConnected(mock ? "mock" : typeof EventSource !== "undefined" ? "sse" : "poll");
|
||||
// mock 模拟流式:密集轮询;真实端点:SSE 为主,2.5 秒轮询兜底
|
||||
const timer = setInterval(() => void pull(), mock ? 150 : 2500);
|
||||
return () => {
|
||||
stopped = true;
|
||||
ctrl.abort();
|
||||
clearInterval(timer);
|
||||
unsubscribe?.();
|
||||
setConnected("idle");
|
||||
};
|
||||
}, [runId, active]);
|
||||
|
||||
return useMemo(() => {
|
||||
const items = foldEvents(events);
|
||||
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
|
||||
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
|
||||
return { events, items, usage, finished: !!end, error, connected };
|
||||
}, [events, error, connected]);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { TimelineEvent, TimelineStage } from "~/api/tasks.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Timeline } from "~/components/console/timeline.tsx";
|
||||
|
||||
// 业务时间线(参考 MovieClaw task-activity):把观察 → 计划 → 下载 → 整理 → Agent 串成一个业务过程,
|
||||
// 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。
|
||||
|
||||
export const STAGE_LABEL: Record<TimelineStage, string> = {
|
||||
observation: "观察",
|
||||
plan: "计划",
|
||||
download: "下载",
|
||||
organization: "整理",
|
||||
agent: "Agent",
|
||||
engine: "引擎",
|
||||
};
|
||||
|
||||
const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"];
|
||||
|
||||
export function BusinessTimeline({ events, loading }: { events: TimelineEvent[]; loading?: boolean }) {
|
||||
const counts = new Map<TimelineStage, number>();
|
||||
for (const e of events) counts.set(e.stage, (counts.get(e.stage) ?? 0) + 1);
|
||||
const ongoing = events.filter((e) => e.ongoing);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ORDER.map((s) => {
|
||||
const n = counts.get(s) ?? 0;
|
||||
const bad = events.some((e) => e.stage === s && e.tone === "destructive");
|
||||
return (
|
||||
<StatusBadge key={s} tone={bad ? "destructive" : n ? "success" : "neutral"}>
|
||||
{STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"}
|
||||
</StatusBadge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{ongoing.length ? (
|
||||
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
|
||||
) : null}
|
||||
{loading && !events.length ? (
|
||||
<LoadingState rows={3} />
|
||||
) : (
|
||||
<Timeline
|
||||
items={events.map((e) => ({
|
||||
id: e.key,
|
||||
at: e.at,
|
||||
tone: e.tone,
|
||||
meta: STAGE_LABEL[e.stage],
|
||||
title: e.href ? (
|
||||
<Link to={e.href} className="inline-flex items-center gap-1 underline-offset-4 hover:underline">
|
||||
{e.title}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
e.title
|
||||
),
|
||||
description: e.detail ?? undefined,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{loading && events.length ? (
|
||||
<p className="text-meta text-muted-foreground">正在补全关联的整理记录与引擎 run…</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Dashboard } from "@umt/contracts";
|
||||
import { Brush, HardDrive } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
type Volume = Dashboard["cache"]["volumes"][number];
|
||||
|
||||
function VolumeBar({ v }: { v: Volume }) {
|
||||
const pct = v.usedPercent;
|
||||
const tripped = pct !== null && pct >= v.hardThresholdPercent;
|
||||
const nearing = pct !== null && !tripped && pct >= v.resumePercent;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{v.volumeKey}</span>
|
||||
</span>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
|
||||
{v.usedBytes !== null && v.totalBytes !== null
|
||||
? ` · ${formatBytes(v.usedBytes, 1)} / ${formatBytes(v.totalBytes, 1)}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-1.5 h-1.5 rounded-full bg-foreground/[0.08]">
|
||||
{pct !== null ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
tripped ? "bg-destructive" : nearing ? "bg-warning" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${Math.min(100, pct)}%` }}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -top-1 h-3.5 w-px bg-destructive"
|
||||
style={{ left: `${v.hardThresholdPercent}%` }}
|
||||
title={`硬阈值 ${v.hardThresholdPercent}%`}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -top-1 h-3.5 w-px bg-muted-foreground"
|
||||
style={{ left: `${v.resumePercent}%` }}
|
||||
title={`恢复水位 ${v.resumePercent}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
|
||||
<span>
|
||||
硬阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}%
|
||||
</span>
|
||||
<span>在途 {formatBytes(v.pendingBytes, 1)}</span>
|
||||
<span>预留 {formatBytes(v.reservedBytes, 1)}</span>
|
||||
<span>可清理 {formatBytes(v.cleanableBytes, 1)}</span>
|
||||
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态:已保守阻止新提交</StatusBadge> : null}
|
||||
{tripped ? <StatusBadge tone="destructive">已达硬阈值:停止新提交</StatusBadge> : null}
|
||||
{v.pausedByThreshold ? <StatusBadge tone="warning">阈值暂停 {v.pausedByThreshold} 个任务</StatusBadge> : null}
|
||||
</div>
|
||||
{v.notes.map((n) => (
|
||||
<p key={n} className="mt-1 text-meta text-muted-foreground">
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 缓存卷用量 / 阈值条 + 手动清理入口 */
|
||||
export function CacheVolumes() {
|
||||
const vols = useCacheVolumes();
|
||||
const cleanup = useCacheCleanup();
|
||||
return (
|
||||
<section className="pg-slab mb-6 rounded-xl p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">缓存与保留</h2>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={cleanup.isPending}>
|
||||
<Brush />
|
||||
立即清理
|
||||
</Button>
|
||||
}
|
||||
title="按规则清理下载缓存?"
|
||||
description="只清理已核验入库、且满足保留期与 H&R 的纳管任务;未纳管任务与未满足保留的不会动。"
|
||||
confirmLabel="清理"
|
||||
onConfirm={() =>
|
||||
cleanup.mutateAsync(undefined).then(
|
||||
(r) => {
|
||||
const vols = (r.report as { volumes?: { removed?: unknown[]; skipped?: unknown[] }[] }).volumes ?? [];
|
||||
const removed = vols.reduce((n, v) => n + (v.removed?.length ?? 0), 0);
|
||||
const skipped = vols.reduce((n, v) => n + (v.skipped?.length ?? 0), 0);
|
||||
toast.success(`清理完成:移除 ${removed} 个任务的缓存${skipped ? `,${skipped} 个因保留/H&R 跳过` : ""}`);
|
||||
},
|
||||
(e) => toast.error(errorMessage(e)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{vols.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : vols.error ? (
|
||||
<ErrorState plain error={vols.error} onRetry={() => void vols.refetch()} />
|
||||
) : !vols.data.length ? (
|
||||
<p className="text-caption text-muted-foreground">还没有下载器缓存卷的统计(未配置下载器或尚未巡检)。</p>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{vols.data.map((v) => (
|
||||
<VolumeBar key={v.volumeKey} v={v} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { DownloadTask } from "@umt/contracts";
|
||||
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DOWNLOAD_STATUS_LABEL,
|
||||
DOWNLOAD_STATUS_TONE,
|
||||
PAUSE_REASON_LABEL,
|
||||
PLAN_STATUS_LABEL,
|
||||
useDownloadAction,
|
||||
useDownloaderNames,
|
||||
usePlan,
|
||||
} from "~/api/downloads.ts";
|
||||
import { useDownloadTrace } from "~/api/tasks.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||
import { OrganizeDialog } from "./organize-dialog.tsx";
|
||||
|
||||
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
|
||||
export function ProgressLine({ t }: { t: DownloadTask }) {
|
||||
const known = t.sizeBytes !== null;
|
||||
return (
|
||||
<div className="min-w-24">
|
||||
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
|
||||
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${Math.round(t.progress * 100)}%` }} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
|
||||
{known ? `${(t.progress * 100).toFixed(1)}%` : `已下 ${formatBytes(t.downloadedBytes, 1)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
|
||||
{t.pauseReason === "threshold" ? (
|
||||
<StatusBadge tone="warning">阈值暂停</StatusBadge>
|
||||
) : t.pauseReason ? (
|
||||
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
|
||||
) : null}
|
||||
{!t.managed ? <StatusBadge>未纳管</StatusBadge> : null}
|
||||
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 暂停/恢复:阈值暂停在水位回落前不可手动恢复(服务端强制) */
|
||||
export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" | "xs" }) {
|
||||
const act = useDownloadAction();
|
||||
const canPause = ["queued", "downloading", "stalled"].includes(t.status);
|
||||
const canResume = t.status === "paused";
|
||||
if (!canPause && !canResume) return null;
|
||||
const threshold = t.pauseReason === "threshold";
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
disabled={act.isPending}
|
||||
title={threshold ? "阈值暂停:缓存回落到恢复水位后由系统自动恢复" : undefined}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
act.mutate(
|
||||
{ id: t.id, action: canResume ? "resume" : "pause" },
|
||||
{
|
||||
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停(记为用户暂停,阈值恢复不会自动恢复它)"),
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{canResume ? <Play /> : <Pause />}
|
||||
{canResume ? "恢复" : "暂停"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
|
||||
export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
const plan = usePlan(task.acquisitionPlanId);
|
||||
const names = useDownloaderNames();
|
||||
const trace = useDownloadTrace(task, plan.data);
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DownloadStatusBadges t={task} />
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<PauseResume t={task} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canOrganize}
|
||||
title={canOrganize ? undefined : "下载完成后才能整理"}
|
||||
onClick={() => setOrganizing(true)}
|
||||
>
|
||||
<FolderInput />
|
||||
整理
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `下载:${task.name}`,
|
||||
downloadTaskId: task.id,
|
||||
goal: task.status === "stalled" || task.status === "failed" ? "排查这个下载为什么没有进展" : "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />问 Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.pauseReason === "threshold" ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
缓存卷达到硬阈值,系统暂停了这个纳管任务。水位回落到恢复线后自动恢复;手动恢复、优先级或 Agent
|
||||
推荐都不能绕过阈值。
|
||||
</p>
|
||||
) : null}
|
||||
{task.noProgressSince ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
自 {formatDateTime(task.noProgressSince)} 起无进展。系统只记录证据并汇入待处理,不会自动换源。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DetailSection title="进度">
|
||||
<ProgressLine t={task} />
|
||||
<KvList
|
||||
className="mt-3"
|
||||
items={[
|
||||
{ label: "下载器", value: names.data?.get(task.instanceId) ?? task.instanceId.slice(0, 8) },
|
||||
{ label: "大小", value: task.sizeBytes === null ? "未知" : formatBytes(task.sizeBytes, 2) },
|
||||
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
|
||||
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
|
||||
{ label: "最近进展", value: relTime(task.lastProgressAt) },
|
||||
{ label: "最近同步", value: relTime(task.lastSyncedAt) },
|
||||
{ label: "标签", value: task.tags.join(", ") },
|
||||
{ label: "分类", value: task.category },
|
||||
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
|
||||
{ label: "内容路径", value: task.contentPath, mono: true, wide: true },
|
||||
{ label: "torrent hash", value: task.torrentHash, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="业务时间线">
|
||||
<BusinessTimeline events={trace.events} loading={trace.loading} />
|
||||
</DetailSection>
|
||||
|
||||
{plan.data ? (
|
||||
<DetailSection title="下载计划">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
|
||||
{ label: "用途", value: plan.data.purpose },
|
||||
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
|
||||
{ label: "说明", value: plan.data.statusReason, wide: true },
|
||||
]}
|
||||
/>
|
||||
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{task.fileList?.length ? (
|
||||
<DetailSection title={`文件(${task.fileList.length})`}>
|
||||
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
|
||||
{task.fileList.map((f) => (
|
||||
<li key={f.path} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatBytes(f.size, 1)}
|
||||
{f.progress !== undefined ? ` · ${Math.round(f.progress * 100)}%` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
|
||||
|
||||
{organizing ? (
|
||||
<OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} downloadTaskId={task.id} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { OrganizeRequest } from "@umt/contracts";
|
||||
import { Eye, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
||||
import { OrganizePlanPreview, useArtifact, useEntitySearch, useLibraryRoots, useOrganize } from "~/api/records.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const AUTO = "__auto__";
|
||||
|
||||
const EXCLUDE_REASON: Record<string, string> = {
|
||||
sample: "样片",
|
||||
too_small: "体积过小",
|
||||
not_imported_kind: "不入库的文件类型",
|
||||
orphan_sidecar: "孤立附属文件",
|
||||
no_episode: "解析不出集号",
|
||||
special_extra: "特典/花絮",
|
||||
extra_content: "额外内容",
|
||||
no_target_dir: "没有目标目录",
|
||||
naming_failed: "命名失败",
|
||||
};
|
||||
|
||||
function PlanPreview({ planId }: { planId: string }) {
|
||||
const art = useArtifact(planId);
|
||||
if (art.isPending) return <LoadingState rows={3} />;
|
||||
if (art.error) {
|
||||
return (
|
||||
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
|
||||
计划已生成(planId {planId.slice(0, 8)}…),
|
||||
{isMissingEndpoint(art.error)
|
||||
? "但后端还没有提供读取计划内容的端点(GET /artifacts/{id}),无法逐文件预览。"
|
||||
: `读取计划失败:${errorMessage(art.error)}`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const parsed = OrganizePlanPreview.safeParse(art.data.content);
|
||||
if (!parsed.success) return <p className="text-caption text-muted-foreground">计划内容格式无法识别。</p>;
|
||||
const p = parsed.data;
|
||||
const org = p.plan?.organize;
|
||||
if (!org)
|
||||
return (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
无法生成计划:{p.reason ?? p.status ?? "未知原因"}
|
||||
</p>
|
||||
);
|
||||
const blocking = org.conflicts.filter((c) => c.blocking);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge>{org.operation}</StatusBadge>
|
||||
{org.crossDevice ? <StatusBadge tone="warning">跨卷:Copy → 核验 → 删源</StatusBadge> : null}
|
||||
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
|
||||
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
|
||||
</div>
|
||||
<ul className="max-h-56 space-y-1.5 overflow-auto text-meta">
|
||||
{org.files.map((f) => (
|
||||
<li key={f.src} className="rounded-lg border border-border/60 px-2.5 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge tone={f.action === "transfer" ? "success" : f.action === "skip" ? "neutral" : "warning"}>
|
||||
{f.action}
|
||||
</StatusBadge>
|
||||
<span className="text-muted-foreground">{f.kind}</span>
|
||||
<span className="text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
||||
{f.skipReason ? <span className="text-muted-foreground">({f.skipReason})</span> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono break-all text-muted-foreground">{f.src}</p>
|
||||
<p className="font-mono break-all text-foreground">→ {f.dst}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{org.excluded.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
排除 {org.excluded.length} 个:
|
||||
{org.excluded
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.src.split("/").pop()}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
|
||||
.join(",")}
|
||||
{org.excluded.length > 5 ? "…" : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{org.conflicts.map((c) => (
|
||||
<p
|
||||
key={`${c.kind}${c.src ?? ""}`}
|
||||
className={cn("text-meta", c.blocking ? "text-destructive" : "text-warning")}
|
||||
>
|
||||
{c.blocking ? "阻塞" : "提示"}:{c.kind}
|
||||
{c.detail ? ` · ${c.detail}` : ""}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动发起整理:先 dryRun 预览计划,再执行。自动与手动产生同格式整理记录。
|
||||
*/
|
||||
export function OrganizeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
downloadTaskId,
|
||||
defaultPaths,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
downloadTaskId?: string;
|
||||
defaultPaths?: string[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const organize = useOrganize();
|
||||
const roots = useLibraryRoots();
|
||||
const [paths, setPaths] = useState((defaultPaths ?? []).join("\n"));
|
||||
const [action, setAction] = useState<"copy" | "move">("copy");
|
||||
const [root, setRoot] = useState("");
|
||||
const [entityQ, setEntityQ] = useState("");
|
||||
const [entity, setEntity] = useState<{ id: string; title: string } | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [planId, setPlanId] = useState<string | null>(null);
|
||||
const search = useEntitySearch(entity ? "" : entityQ);
|
||||
|
||||
const body = (dryRun: boolean) => {
|
||||
const sourcePaths = paths
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return OrganizeRequest.safeParse({
|
||||
...(downloadTaskId ? { downloadTaskId } : { sourcePaths }),
|
||||
...(entity ? { mediaEntityId: entity.id } : {}),
|
||||
...(root ? { targetLibraryRootId: root } : {}),
|
||||
action,
|
||||
dryRun,
|
||||
});
|
||||
};
|
||||
|
||||
const run = (dryRun: boolean) => {
|
||||
const parsed = body(dryRun);
|
||||
if (!parsed.success) {
|
||||
const e = zodFieldErrors(parsed.error.issues);
|
||||
const pathErr = Object.entries(e).find(([k]) => k.startsWith("sourcePaths"));
|
||||
setErrors({
|
||||
...e,
|
||||
paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径(每行一个绝对路径)" : "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
organize.mutate(parsed.data, {
|
||||
onSuccess: (r) => {
|
||||
if (dryRun) setPlanId(r.planId);
|
||||
else {
|
||||
toast.success("已发起整理");
|
||||
onOpenChange(false);
|
||||
if (r.recordId) navigate(`/console/organize/${r.recordId}`);
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>手动整理</DialogTitle>
|
||||
<DialogDescription>
|
||||
先预览计划(不动文件),确认后再执行。Move 跨卷时按 Copy → 核验 → 删源执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-1">
|
||||
{downloadTaskId ? (
|
||||
<p className="mb-2 text-caption text-muted-foreground">
|
||||
源:下载任务 {downloadTaskId.slice(0, 8)}… 的内容路径
|
||||
</p>
|
||||
) : (
|
||||
<FormField
|
||||
label="源路径"
|
||||
htmlFor="org-paths"
|
||||
required
|
||||
error={errors.paths}
|
||||
hint="执行进程可访问的绝对路径,每行一个(文件或目录)"
|
||||
>
|
||||
<Textarea
|
||||
id="org-paths"
|
||||
rows={3}
|
||||
className="font-mono text-caption"
|
||||
value={paths}
|
||||
aria-invalid={!!errors.paths}
|
||||
onChange={(e) => {
|
||||
setPaths(e.target.value);
|
||||
setPlanId(null);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="作品" htmlFor="org-entity" hint={entity ? undefined : "可选;不选时按文件名识别"}>
|
||||
{entity ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge>{entity.title}</StatusBadge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setEntity(null);
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
更换
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="org-entity"
|
||||
value={entityQ}
|
||||
placeholder="搜索本地作品"
|
||||
onChange={(e) => setEntityQ(e.target.value)}
|
||||
/>
|
||||
{search.data?.items.length ? (
|
||||
<ul className="pg-float absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-auto rounded-xl p-1">
|
||||
{search.data.items.map((it) => (
|
||||
<li key={it.entity.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer rounded-lg px-2.5 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
|
||||
onClick={() => {
|
||||
setEntity({
|
||||
id: it.entity.id,
|
||||
title: `${it.entity.title}${it.entity.year ? ` (${it.entity.year})` : ""}`,
|
||||
});
|
||||
setEntityQ("");
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
{it.entity.title}
|
||||
{it.entity.year ? ` (${it.entity.year})` : ""}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="动作"
|
||||
htmlFor="org-action"
|
||||
hint={action === "move" ? "Move 需要对应授权" : "保留源文件(做种不受影响)"}
|
||||
>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => {
|
||||
setAction(v as "copy" | "move");
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="org-action" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="copy">Copy</SelectItem>
|
||||
<SelectItem value="move">Move</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="目标库根" htmlFor="org-root">
|
||||
<Select
|
||||
value={root || AUTO}
|
||||
onValueChange={(v) => {
|
||||
setRoot(v === AUTO ? "" : v);
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="org-root" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={AUTO}>按作品类型自动</SelectItem>
|
||||
{(roots.data?.items ?? []).map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
{planId ? (
|
||||
<div className="mt-1">
|
||||
<p className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
计划预览
|
||||
</p>
|
||||
<PlanPreview planId={planId} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => run(true)} disabled={organize.isPending}>
|
||||
<Eye />
|
||||
预览计划
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => run(false)}
|
||||
disabled={organize.isPending || !planId}
|
||||
title={planId ? undefined : "先预览计划"}
|
||||
>
|
||||
<Play />
|
||||
执行
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
|
||||
import { Bot, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
||||
import { useSubmitRelease } from "~/api/releases.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划并预留 Slot 与预算(同一 Slot 同时只有一个在途计划)",
|
||||
"去重:已有相同资源或同 hash 任务时走接管,不重复提交",
|
||||
"缓存卷硬阈值检查:超过阈值或读不到磁盘状态时阻止新提交,不能手动绕过",
|
||||
"选择下载器实例并打管理标签提交;下单前复核免费/做种等易变字段",
|
||||
];
|
||||
|
||||
/**
|
||||
* 统一下载入口:与自动收录走同一提交流程,不在前端直接调下载器。
|
||||
*/
|
||||
export function DownloadDialog({
|
||||
release,
|
||||
slots,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
slots: AcquisitionSlot[];
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const submit = useSubmitRelease();
|
||||
const [slotId, setSlotId] = useState("");
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const go = () =>
|
||||
submit.mutate(
|
||||
{ sourceReleaseId: release.id, ...(slotId ? { slotId } : {}) },
|
||||
{
|
||||
onSuccess: (plan) => {
|
||||
setDone(plan.id);
|
||||
toast.success(`已生成下载计划(${plan.status}),进度见“下载与任务”`);
|
||||
},
|
||||
onError: (e) => {
|
||||
if (isMissingEndpoint(e)) setMissing(true);
|
||||
else toast.error(errorMessage(e));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setMissing(false);
|
||||
setDone(null);
|
||||
}
|
||||
onOpenChange(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下载这个资源</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{release.title}
|
||||
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
|
||||
{STEPS.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
{slots.length ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label="版本目标"
|
||||
value={slotId}
|
||||
onChange={setSlotId}
|
||||
allLabel="自动匹配"
|
||||
options={slots.map((s) => ({ value: s.id, label: `${s.name}(${s.status})` }))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{release.entity
|
||||
? "该作品还没有版本目标,提交时按默认规则建临时目标。"
|
||||
: "资源尚未识别到本地作品,提交后先进入识别。"}
|
||||
</p>
|
||||
)}
|
||||
{missing ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
后端还没有开放手动提交端点(POST /acquisition-plans)。可以委托 Agent 用 download.plan / download.submit
|
||||
能力走同一流程提交。
|
||||
</div>
|
||||
) : null}
|
||||
{done ? (
|
||||
<p className="text-caption text-success">
|
||||
计划已生成。
|
||||
<Link to="/console/tasks" className="underline underline-offset-4">
|
||||
去“下载与任务”查看
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
openAgentDrawer({
|
||||
label: `资源:${release.title}`,
|
||||
releaseId: release.id,
|
||||
entityId: release.entity?.id,
|
||||
siteId: release.siteId,
|
||||
goal: "判断这个资源是否值得下载;合适就按正常流程提交",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Bot />
|
||||
委托 Agent
|
||||
</Button>
|
||||
<Button onClick={go} disabled={submit.isPending || !!done}>
|
||||
<Download />
|
||||
{submit.isPending ? "提交中…" : "提交下载"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
type FeedPurpose,
|
||||
type FeedSource,
|
||||
FeedSourceCreate,
|
||||
FeedSourceUpdate,
|
||||
type Site,
|
||||
type SiteAccount,
|
||||
} from "@umt/contracts";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateFeed, useUpdateFeed } from "~/api/feeds.ts";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
export const PURPOSE_LABEL: Record<FeedPurpose, string> = {
|
||||
movie: "电影",
|
||||
series: "剧集",
|
||||
variety: "综艺",
|
||||
promotion: "促销",
|
||||
search: "搜索",
|
||||
generic: "通用",
|
||||
};
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
type Draft = {
|
||||
name: string;
|
||||
url: string;
|
||||
purpose: FeedPurpose;
|
||||
siteId: string;
|
||||
siteAccountId: string;
|
||||
pollMin: string;
|
||||
filter: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function initial(feed: FeedSource | null): Draft {
|
||||
return {
|
||||
name: feed?.name ?? "",
|
||||
url: "",
|
||||
purpose: feed?.purpose ?? "generic",
|
||||
siteId: feed?.siteId ?? "",
|
||||
siteAccountId: feed?.siteAccountId ?? "",
|
||||
pollMin: String(Math.round((feed?.pollIntervalSec ?? 900) / 60)),
|
||||
filter: feed && Object.keys(feed.filter).length ? JSON.stringify(feed.filter, null, 2) : "",
|
||||
enabled: feed?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 新建 / 编辑 Feed:URL 只写(含 passkey),编辑时留空表示不变 */
|
||||
export function FeedFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feed,
|
||||
sites,
|
||||
accounts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
feed: FeedSource | null;
|
||||
sites: Site[];
|
||||
accounts: SiteAccount[];
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(() => initial(feed));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const create = useCreateFeed();
|
||||
const update = useUpdateFeed();
|
||||
const pending = create.isPending || update.isPending;
|
||||
const siteAccounts = accounts.filter((a) => a.siteId === d.siteId);
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v }));
|
||||
|
||||
const submit = () => {
|
||||
let filter: Record<string, unknown> | undefined;
|
||||
if (d.filter.trim()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(d.filter);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not object");
|
||||
filter = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
setErrors({ filter: "过滤条件必须是 JSON 对象" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const raw = {
|
||||
name: d.name.trim(),
|
||||
purpose: d.purpose,
|
||||
siteId: d.siteId || null,
|
||||
siteAccountId: d.siteAccountId || null,
|
||||
pollIntervalSec: Math.round(Number(d.pollMin) * 60),
|
||||
enabled: d.enabled,
|
||||
...(filter !== undefined ? { filter } : feed ? { filter: {} } : {}),
|
||||
...(d.url.trim() ? { url: d.url.trim() } : {}),
|
||||
};
|
||||
const parsed = feed ? FeedSourceUpdate.safeParse(raw) : FeedSourceCreate.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const e = zodFieldErrors(parsed.error.issues);
|
||||
if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟";
|
||||
if (e.url) e.url = feed ? "URL 格式不对" : "需要完整的 Feed URL";
|
||||
if (e.name) e.name = "名称不能为空";
|
||||
setErrors(e);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast.success(feed ? "已保存" : "已新建 Feed,会按轮询策略抓取");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: unknown) => toast.error(errorMessage(e)),
|
||||
};
|
||||
if (feed) update.mutate({ id: feed.id, body: parsed.data as FeedSourceUpdate }, done);
|
||||
else create.mutate(parsed.data as FeedSourceCreate, done);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{feed ? "编辑 Feed" : "新建 Feed"}</DialogTitle>
|
||||
<DialogDescription>URL 可能含 passkey,只写入加密凭证,之后只显示脱敏地址。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<FormField label="名称" htmlFor="feed-name" required error={errors.name}>
|
||||
<Input
|
||||
id="feed-name"
|
||||
value={d.name}
|
||||
aria-invalid={!!errors.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="URL"
|
||||
htmlFor="feed-url"
|
||||
required={!feed}
|
||||
error={errors.url}
|
||||
hint={feed ? `当前:${feed.urlDisplay};留空表示不变` : "RSS / Atom 地址"}
|
||||
>
|
||||
<Input
|
||||
id="feed-url"
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
value={d.url}
|
||||
aria-invalid={!!errors.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => set("url", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
<FormField label="用途" htmlFor="feed-purpose">
|
||||
<Select value={d.purpose} onValueChange={(v) => set("purpose", v as FeedPurpose)}>
|
||||
<SelectTrigger id="feed-purpose" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{Object.entries(PURPOSE_LABEL).map(([v, l]) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="轮询间隔(分钟)" htmlFor="feed-poll" error={errors.pollMin}>
|
||||
<Input
|
||||
id="feed-poll"
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={d.pollMin}
|
||||
aria-invalid={!!errors.pollMin}
|
||||
onChange={(e) => set("pollMin", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="站点" htmlFor="feed-site">
|
||||
<Select
|
||||
value={d.siteId || NONE}
|
||||
onValueChange={(v) => setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }))}
|
||||
>
|
||||
<SelectTrigger id="feed-site" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不关联站点</SelectItem>
|
||||
{sites.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="站点账号"
|
||||
htmlFor="feed-account"
|
||||
hint={d.siteId && !siteAccounts.length ? "该站点没有账号" : undefined}
|
||||
>
|
||||
<Select
|
||||
value={d.siteAccountId || NONE}
|
||||
disabled={!d.siteId}
|
||||
onValueChange={(v) => set("siteAccountId", v === NONE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="feed-account" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不指定</SelectItem>
|
||||
{siteAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="过滤条件(JSON,可选)"
|
||||
htmlFor="feed-filter"
|
||||
error={errors.filter}
|
||||
hint='如 {"include":["2160p"],"exclude":["CAM"]}'
|
||||
>
|
||||
<Textarea
|
||||
id="feed-filter"
|
||||
rows={3}
|
||||
className="font-mono text-caption"
|
||||
value={d.filter}
|
||||
aria-invalid={!!errors.filter}
|
||||
onChange={(e) => set("filter", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 py-1 text-caption text-foreground">
|
||||
<label htmlFor="feed-enabled">启用(关闭后停止轮询,历史观察保留)</label>
|
||||
<Switch id="feed-enabled" checked={d.enabled} onCheckedChange={(v) => set("enabled", v)} />
|
||||
</div>
|
||||
<DialogFooter className="mt-2 gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { Bot, Download, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntityDetail } from "~/api/inventory.ts";
|
||||
import { useRefreshReleaseDetail, useReleaseDetail } from "~/api/releases.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { DownloadDialog } from "./download-dialog.tsx";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts";
|
||||
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
|
||||
|
||||
const DECISION_LABEL = {
|
||||
accept: { label: "符合,可收录", tone: "success" },
|
||||
upgrade: { label: "可升级", tone: "success" },
|
||||
reject: { label: "不收录", tone: "destructive" },
|
||||
unknown_field: { label: "缺字段,待复核", tone: "warning" },
|
||||
skipped: { label: "被跳过", tone: "neutral" },
|
||||
} as const;
|
||||
|
||||
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
|
||||
function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry: InventoryEntry | undefined }) {
|
||||
const detail = useEntityDetail(release.entity?.id);
|
||||
if (!release.entity) {
|
||||
return (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
尚未识别到本地作品({release.resolutionStatus}),不显示库存推断。
|
||||
<Link to="/console/identity" className="ml-1 underline underline-offset-4">
|
||||
去身份与元数据处理
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const candidates = detail.data?.candidates.filter((c) => c.sourceReleaseId === release.id) ?? [];
|
||||
const slots = detail.data?.slots ?? [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<InventoryOverlay release={release} entry={entry} loading={false} />
|
||||
{entry?.versions.length ? (
|
||||
<ul className="space-y-1 text-caption">
|
||||
{entry.versions.map((v) => (
|
||||
<li key={v.assetId} className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={v.available ? "success" : "warning"}>{v.available ? "可用" : "不可用"}</StatusBadge>
|
||||
<span className="text-foreground">
|
||||
{[v.resolution, v.sourceType, v.releaseGroup, v.contentEdition].filter(Boolean).join(" · ") ||
|
||||
"版本特征未知"}
|
||||
</span>
|
||||
{v.sizeBytes ? <span className="text-muted-foreground">{formatBytes(v.sizeBytes, 1)}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{detail.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : detail.error ? (
|
||||
<p className="text-meta text-destructive">作品详情读取失败:{errorMessage(detail.error)}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{slots.length ? `${slots.length} 个版本目标(已订阅)` : "没有版本目标(未订阅)"}
|
||||
</p>
|
||||
{candidates.length ? (
|
||||
<ul className="space-y-2">
|
||||
{candidates.map((c) => {
|
||||
const d = DECISION_LABEL[c.decision];
|
||||
const slot = slots.find((s) => s.id === c.slotId);
|
||||
return (
|
||||
<li key={`${c.slotId}-${c.evaluatedAt}`} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-caption">
|
||||
<StatusBadge tone={d.tone}>{d.label}</StatusBadge>
|
||||
<span className="text-foreground">{slot ? `目标 ${slot.name}` : "版本目标"}</span>
|
||||
<span className="text-muted-foreground">{relTime(c.evaluatedAt)}评估</span>
|
||||
</div>
|
||||
{c.reasons.length ? (
|
||||
<ul className="mt-1 list-disc pl-5 text-meta text-muted-foreground">
|
||||
{c.reasons.map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{c.rejected.length ? (
|
||||
<ul className="mt-1 list-disc pl-5 text-meta text-destructive">
|
||||
{c.rejected.map((r) => (
|
||||
<li key={r.code}>{r.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">规则引擎还没评估过这条资源。</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDetailSheet({
|
||||
siteId,
|
||||
release,
|
||||
entry,
|
||||
onClose,
|
||||
}: {
|
||||
siteId: string;
|
||||
release: WorkbenchRelease | null;
|
||||
entry: InventoryEntry | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const detail = useReleaseDetail(siteId, release?.id);
|
||||
const refresh = useRefreshReleaseDetail();
|
||||
const entity = useEntityDetail(release?.entity?.id);
|
||||
const [dl, setDl] = useState(false);
|
||||
const r = detail.data?.release ?? release;
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!release}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="lg"
|
||||
title={r?.title ?? ""}
|
||||
description={r?.subtitle ?? undefined}
|
||||
footer={
|
||||
r ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `资源:${r.title}`,
|
||||
releaseId: r.id,
|
||||
entityId: r.entity?.id,
|
||||
siteId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />问 Agent
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setDl(true)}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!r ? null : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<ReleaseBadges r={r} />
|
||||
<StatusBadge>{ORIGIN_LABEL[r.origin]}</StatusBadge>
|
||||
</div>
|
||||
|
||||
<DetailSection
|
||||
title="易变字段复核"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(
|
||||
{ siteId, rid: r.id },
|
||||
{
|
||||
onSuccess: (d) =>
|
||||
d.refreshError
|
||||
? toast.warning(`复核失败,保留既有数据:${d.refreshError}`)
|
||||
: toast.success("已从站点复核详情"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
复核详情
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{detail.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : detail.error ? (
|
||||
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
|
||||
) : detail.data ? (
|
||||
<>
|
||||
{detail.data.refreshError ? (
|
||||
<p className="mb-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
最近一次复核失败,以下为既有数据(已过期):{detail.data.refreshError}
|
||||
</p>
|
||||
) : null}
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "促销观察于", value: formatDateTime(detail.data.volatile.promotionObservedAt) },
|
||||
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
|
||||
{
|
||||
label: "做种 / 下载",
|
||||
value: `${r.seeders ?? "?"} / ${r.leechers ?? "?"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
|
||||
},
|
||||
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-meta text-muted-foreground">
|
||||
缓存命中不代替下单前复核:提交下载时会再次核对免费与做种状态。
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="本地库存与候选理由">
|
||||
<LocalInventory release={r} entry={entry} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="资源信息">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "分类", value: categoryLabel(r) },
|
||||
{ label: "大小", value: r.sizeBytes === null ? "未知" : formatBytes(r.sizeBytes, 2) },
|
||||
{ label: "H&R", value: { yes: "有", no: "无", unknown: "未知(按有义务处理)" }[hrOf(r)] },
|
||||
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
|
||||
{ label: "首次发现", value: formatDateTime(r.firstSeenAt) },
|
||||
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
|
||||
{ label: "Feed 观察次数", value: r.observedByFeeds },
|
||||
{ label: "站点资源 ID", value: r.siteReleaseId, mono: true },
|
||||
{ label: "infohash", value: r.infohashV1 ?? r.infohashV2, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
{detail.data?.observations.length ? (
|
||||
<DetailSection title="Feed 观察">
|
||||
<ul className="space-y-1 text-caption">
|
||||
{detail.data.observations.map((o) => (
|
||||
<li key={o.observationId} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="text-foreground">{o.feedName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDateTime(o.firstSeenAt)} → {relTime(o.lastSeenAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{r.fileList?.length ? (
|
||||
<DetailSection title={`文件(${r.fileList.length})`}>
|
||||
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
|
||||
{r.fileList.map((f) => (
|
||||
<li key={f.path} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
<JsonView label="解析结果" value={r.parsed} />
|
||||
</>
|
||||
)}
|
||||
{r ? <DownloadDialog release={r} slots={entity.data?.slots ?? []} open={dl} onOpenChange={setDl} /> : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Site, SiteAccount, SiteHealth, SourceRelease, WorkbenchRelease } from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// 站点资源字段的展示口径:缺失字段按"未知"显示,不当成 0 / 否。
|
||||
|
||||
type SiteExtra = { hr?: unknown; categoryName?: unknown; snatched?: unknown; detailUrl?: unknown };
|
||||
|
||||
export function siteExtra(r: Pick<SourceRelease, "raw">): SiteExtra {
|
||||
const s = (r.raw as { _site?: SiteExtra })._site;
|
||||
return s && typeof s === "object" ? s : {};
|
||||
}
|
||||
|
||||
/** H&R 三态 */
|
||||
export function hrOf(r: Pick<SourceRelease, "raw">): "yes" | "no" | "unknown" {
|
||||
const hr = siteExtra(r).hr;
|
||||
return hr === true ? "yes" : hr === false ? "no" : "unknown";
|
||||
}
|
||||
|
||||
export function categoryLabel(r: Pick<SourceRelease, "raw" | "categoryRaw">): string | null {
|
||||
const name = siteExtra(r).categoryName;
|
||||
return typeof name === "string" && name ? name : r.categoryRaw;
|
||||
}
|
||||
|
||||
const PROMO_LABEL: Record<string, string> = {
|
||||
free: "免费",
|
||||
"2xfree": "2x 免费",
|
||||
"50%": "50%",
|
||||
"2x50%": "2x 50%",
|
||||
"30%": "30%",
|
||||
"2x": "2x 上传",
|
||||
};
|
||||
|
||||
/** 促销标签;none / unknown 返回 null */
|
||||
export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionExpiresAt">, now = Date.now()) {
|
||||
const kind = r.promotion.kind;
|
||||
if (!kind || kind === "none" || kind === "unknown") return null;
|
||||
const expired = r.promotionExpiresAt ? new Date(r.promotionExpiresAt).getTime() < now : false;
|
||||
return { label: PROMO_LABEL[kind] ?? kind, free: kind === "free" || kind === "2xfree", expired };
|
||||
}
|
||||
|
||||
export const isFree = (r: Pick<SourceRelease, "promotion">) =>
|
||||
r.promotion.kind === "free" || r.promotion.kind === "2xfree";
|
||||
|
||||
/** 相对时间(中文);未来时间显示"x 后" */
|
||||
export function relTime(iso: string | null | undefined, now = Date.now()): string {
|
||||
if (!iso) return "—";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const diff = now - t;
|
||||
const abs = Math.abs(diff);
|
||||
const suffix = diff >= 0 ? "前" : "后";
|
||||
if (abs < 60_000) return diff >= 0 ? "刚刚" : "即将";
|
||||
if (abs < 3_600_000) return `${Math.round(abs / 60_000)} 分钟${suffix}`;
|
||||
if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)} 小时${suffix}`;
|
||||
if (abs < 30 * 86_400_000) return `${Math.round(abs / 86_400_000)} 天${suffix}`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
export const ORIGIN_LABEL: Record<WorkbenchRelease["origin"], string> = {
|
||||
snapshot: "快照",
|
||||
feed: "Feed",
|
||||
live: "实时补查",
|
||||
unknown: "来源未知",
|
||||
};
|
||||
|
||||
export const SITE_HEALTH: Record<SiteHealth, { label: string; tone: StatusTone }> = {
|
||||
unknown: { label: "健康未知", tone: "neutral" },
|
||||
healthy: { label: "健康", tone: "success" },
|
||||
degraded: { label: "降级", tone: "warning" },
|
||||
failing: { label: "故障", tone: "destructive" },
|
||||
disabled: { label: "已停用", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const ACCOUNT_STATUS: Record<SiteAccount["status"], { label: string; tone: StatusTone }> = {
|
||||
unknown: { label: "未验证", tone: "neutral" },
|
||||
active: { label: "有效", tone: "success" },
|
||||
invalid: { label: "凭证无效", tone: "destructive" },
|
||||
expired: { label: "已过期", tone: "warning" },
|
||||
};
|
||||
|
||||
/** 站点下的账号总体状态:有效优先,其次最差的那个 */
|
||||
export function accountSummary(accounts: SiteAccount[]): { label: string; tone: StatusTone } {
|
||||
const enabled = accounts.filter((a) => a.enabled);
|
||||
if (!enabled.length) return { label: accounts.length ? "账号已停用" : "未配置账号", tone: "neutral" };
|
||||
if (enabled.some((a) => a.status === "active")) return ACCOUNT_STATUS.active;
|
||||
for (const s of ["invalid", "expired", "unknown"] as const)
|
||||
if (enabled.some((a) => a.status === s)) return ACCOUNT_STATUS[s];
|
||||
return ACCOUNT_STATUS.unknown;
|
||||
}
|
||||
|
||||
export function circuitLabel(site: Pick<Site, "circuitState" | "circuitOpenUntil">): string | null {
|
||||
if (site.circuitState === "closed") return null;
|
||||
if (site.circuitState === "half_open") return "熔断半开(试探中)";
|
||||
return `熔断中${site.circuitOpenUntil ? `,${relTime(site.circuitOpenUntil)}恢复` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Clock } from "lucide-react";
|
||||
import { inventoryFlags } from "~/api/inventory.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts";
|
||||
|
||||
/** 促销 / H&R 徽章(列表与详情共用) */
|
||||
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
const promo = promotionLabel(r);
|
||||
const hr = hrOf(r);
|
||||
return (
|
||||
<>
|
||||
{promo ? (
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
|
||||
export function InventoryOverlay({
|
||||
release,
|
||||
entry,
|
||||
loading,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity) {
|
||||
const label =
|
||||
release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
return <span className="text-meta text-muted-foreground">{label}</span>;
|
||||
}
|
||||
const flags = inventoryFlags(entry, release.resolutionStatus);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-meta text-foreground">
|
||||
{release.entity.title}
|
||||
{release.entity.year ? ` (${release.entity.year})` : ""}
|
||||
</span>
|
||||
{loading ? <span className="text-meta text-muted-foreground">库存加载中…</span> : null}
|
||||
{!loading && entry && flags.length === 0 ? <StatusBadge>本地无版本</StatusBadge> : null}
|
||||
{flags.map((f) => (
|
||||
<StatusBadge key={f.key} tone={f.tone}>
|
||||
{f.label}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseRow({
|
||||
r,
|
||||
entry,
|
||||
inventoryLoading,
|
||||
staleBefore,
|
||||
selected,
|
||||
onOpen,
|
||||
}: {
|
||||
r: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
inventoryLoading: boolean;
|
||||
/** 早于此时间确认的数据标"过期" */
|
||||
staleBefore: number | null;
|
||||
selected: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
|
||||
const cat = categoryLabel(r);
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
selected && "bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<ReleaseBadges r={r} />
|
||||
</span>
|
||||
</div>
|
||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
{cat ? <span>{cat}</span> : null}
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.seeders ?? "?"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
{r.leechers ?? "?"}
|
||||
</span>
|
||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
||||
</span>
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Site, SiteSnapshot } from "@umt/contracts";
|
||||
import { Camera } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { snapshotStale, useSiteSnapshots, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { relTime, SITE_HEALTH } from "./release-fields.ts";
|
||||
|
||||
const KIND: Record<SiteSnapshot["kind"], string> = {
|
||||
periodic: "定期快照",
|
||||
backfill: "缺口补漏",
|
||||
live_search: "实时补查",
|
||||
manual: "手动快照",
|
||||
};
|
||||
const STATUS: Record<SiteSnapshot["status"], { label: string; tone: StatusTone }> = {
|
||||
running: { label: "进行中", tone: "neutral" },
|
||||
ok: { label: "完成", tone: "success" },
|
||||
partial: { label: "部分完成", tone: "warning" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
};
|
||||
|
||||
function duration(s: SiteSnapshot) {
|
||||
if (!s.finishedAt) return null;
|
||||
const ms = new Date(s.finishedAt).getTime() - new Date(s.startedAt).getTime();
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
/** 单个站点的快照状态 + 最近记录 + 手动快照 */
|
||||
export function SnapshotPanel({ site }: { site: Site }) {
|
||||
const snaps = useSiteSnapshots(site.id, 8);
|
||||
const snap = useSnapshotSite();
|
||||
const health = SITE_HEALTH[site.healthStatus];
|
||||
const stale = site.snapshotEnabled && snapshotStale(site);
|
||||
return (
|
||||
<div className="pg-slab min-w-0 rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lead font-semibold text-foreground">{site.name}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
|
||||
{!site.enabled ? <StatusBadge tone="warning">站点已停用</StatusBadge> : null}
|
||||
{site.snapshotEnabled ? (
|
||||
<StatusBadge tone={stale ? "warning" : "success"}>
|
||||
{stale ? "快照过期" : "快照新鲜"} · {relTime(site.lastSnapshotAt)}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge>未开启定期快照</StatusBadge>
|
||||
)}
|
||||
{site.snapshotIntervalSec ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
每 {Math.round(site.snapshotIntervalSec / 60)} 分钟
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!site.enabled || snap.isPending}
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: site.id, maxPages: 2 },
|
||||
{
|
||||
onSuccess: () => toast.success(`${site.name}:已排队手动快照`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
手动快照
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{snaps.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : snaps.error ? (
|
||||
<ErrorState plain error={snaps.error} onRetry={() => void snaps.refetch()} />
|
||||
) : !snaps.data.items.length ? (
|
||||
<p className="text-caption text-muted-foreground">还没有快照记录。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60 text-caption">
|
||||
{snaps.data.items.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
|
||||
<StatusBadge tone={STATUS[s.status].tone}>{STATUS[s.status].label}</StatusBadge>
|
||||
<span className="text-foreground">{KIND[s.kind]}</span>
|
||||
{s.query ? <span className="min-w-0 truncate text-muted-foreground">“{s.query}”</span> : null}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{s.pages.length ? `第 ${s.pages.join(",")} 页 · ` : ""}
|
||||
{s.itemCount} 条(新 {s.newCount})
|
||||
</span>
|
||||
<span className="ml-auto text-meta text-muted-foreground" title={formatDateTime(s.startedAt)}>
|
||||
{relTime(s.startedAt)}
|
||||
{duration(s) ? ` · ${duration(s)}` : ""}
|
||||
</span>
|
||||
{s.error ? (
|
||||
<span className="w-full text-meta break-words text-destructive">
|
||||
{String((s.error as { message?: unknown }).message ?? JSON.stringify(s.error))}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,31 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
export default function AgentRunPage() {
|
||||
const { runId = "" } = useParams();
|
||||
const run = useAgentRun(runId);
|
||||
const mock = agentMockEnabled();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description={run.data ? <span className="line-clamp-2 break-words">{run.data.run.goal}</span> : "加载中…"}
|
||||
actions={
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={`/console/agent${mock ? "?mock=1" : ""}`}>
|
||||
<ArrowLeft />
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="pg-slab rounded-xl p-4 md:p-5">
|
||||
<AgentRunPanel runId={runId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,109 @@
|
||||
import { Bot, Send } from "lucide-react";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { Bot, FlaskConical } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
||||
import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx";
|
||||
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
const COLUMNS: DataColumn<AgentRunView>[] = [
|
||||
{
|
||||
key: "goal",
|
||||
header: "目标",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="line-clamp-2 break-words text-foreground">{r.goal}</p>
|
||||
{r.summary ? <p className="mt-0.5 line-clamp-1 text-meta text-muted-foreground">{r.summary}</p> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (r) => <RunStatusBadge status={r.status} /> },
|
||||
{
|
||||
key: "mode",
|
||||
header: "模式",
|
||||
hideBelow: "md",
|
||||
cell: (r) => ((r.mode ?? r.scope.mode) === "plan_only" ? "只要方案" : "执行"),
|
||||
},
|
||||
{ key: "tools", header: "工具调用", hideBelow: "md", align: "right", cell: (r) => r.toolCallCount },
|
||||
{
|
||||
key: "at",
|
||||
header: "开始",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{relTime(r.createdAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export default function AgentPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const runs = useAgentRuns((status || undefined) as AgentRunStatus | undefined);
|
||||
const create = useCreateAgentRun();
|
||||
const navigate = useNavigate();
|
||||
const mock = agentMockEnabled();
|
||||
const missing = !mock && isMissingEndpoint(runs.error);
|
||||
const rows = runs.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
/** Agent 会话页占位:消息流 + 输入框骨架,W2/W3 接入 agent_run 与 SSE 增量 */
|
||||
export default function AgentSession() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
|
||||
/>
|
||||
<div className="flex min-h-[60vh] flex-col gap-4">
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
title="还没有会话"
|
||||
description="例如:“把某部电影的 1080p 收进来”。会话能力计划在 W2 接入。"
|
||||
className="flex-1"
|
||||
/>
|
||||
<form className="flex items-center gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<Input disabled placeholder="Agent 尚未接入" className="flex-1" />
|
||||
<Button type="submit" disabled size="icon" aria-label="发送">
|
||||
<Send className="h-4 w-4" />
|
||||
{missing ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
|
||||
<FlaskConical className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">Agent 会话端点还没上线(W2-D 实现中)。可以先用 mock 数据预览交互。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=1")}>
|
||||
用 mock 预览
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pg-slab mb-6 rounded-xl p-4">
|
||||
<GoalComposer
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
create.mutate(
|
||||
{ goal, mode },
|
||||
{
|
||||
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar onReset={status ? () => setStatus("") : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(RUN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
rows={missing ? [] : rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={runs.isPending}
|
||||
error={missing ? undefined : runs.error}
|
||||
onRetry={() => void runs.refetch()}
|
||||
empty={{
|
||||
icon: Bot,
|
||||
title: missing ? "会话端点未就绪" : "还没有会话",
|
||||
description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。",
|
||||
}}
|
||||
onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)}
|
||||
hasMore={runs.hasNextPage}
|
||||
onLoadMore={() => void runs.fetchNextPage()}
|
||||
loadingMore={runs.isFetchingNextPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "luc
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||
import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
@@ -222,6 +223,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AgentDrawerTrigger />
|
||||
<ThemeToggle />
|
||||
<AccountMenu session={session} />
|
||||
</div>
|
||||
@@ -232,6 +234,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<AgentDrawer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,234 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
|
||||
import { ArrowLeft, ArrowRight, Bot, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const ACTION_LABEL = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
rename: "重命名",
|
||||
relocate: "目录调整",
|
||||
hardlink: "硬链接",
|
||||
} as const;
|
||||
const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "库根相对" } as const;
|
||||
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
running: { label: "执行中", tone: "neutral" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
skipped: { label: "跳过", tone: "neutral" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
const ROLE_LABEL = {
|
||||
main: "主视频",
|
||||
subtitle: "字幕",
|
||||
audio: "音轨",
|
||||
bluray_dir: "原盘目录",
|
||||
nfo: "NFO",
|
||||
image: "图片",
|
||||
other: "其他",
|
||||
} as const;
|
||||
|
||||
/** 稳定 code 与说明不同时才附上 code */
|
||||
function reasonText(r: { code: string; message: string }) {
|
||||
return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message;
|
||||
}
|
||||
|
||||
function PathBlock({ label, p }: { label: string; p: PublicPathRef }) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{label} · {VIEW_LABEL[p.view]}
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-caption break-all text-foreground">{p.path ?? "未知或未授权"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function duration(a: string | null, b: string | null) {
|
||||
if (!a || !b) return null;
|
||||
const s = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
|
||||
return s < 60 ? `${s.toFixed(1)} 秒` : `${Math.round(s / 60)} 分钟`;
|
||||
}
|
||||
|
||||
function FileRow({ f }: { f: PublicOrganizationFile }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const st = FILE_STATUS[f.status];
|
||||
const reason = f.failure ?? f.skipReason;
|
||||
return (
|
||||
<li className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{ROLE_LABEL[f.role]}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-meta text-foreground">
|
||||
{(f.target.path ?? f.source.path ?? "").split("/").pop()}
|
||||
</span>
|
||||
{f.sizeBytes !== null ? (
|
||||
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
|
||||
{formatBytes(f.sizeBytes, 1)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-2 px-3 pb-3 pl-9">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<PathBlock label="整理前" p={f.source} />
|
||||
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
|
||||
<PathBlock label="整理后" p={f.target} />
|
||||
</div>
|
||||
{reason ? (
|
||||
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
|
||||
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
|
||||
{duration(f.startedAt, f.finishedAt) ? ` · 用时 ${duration(f.startedAt, f.finishedAt)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrganizeRecordPage() {
|
||||
const { id = "" } = useParams();
|
||||
const rec = useRecord(id);
|
||||
const r = rec.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
description={
|
||||
r?.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : r ? "未关联作品" : "加载中…"
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/organize">
|
||||
<ArrowLeft />
|
||||
全部记录
|
||||
</Link>
|
||||
</Button>
|
||||
{r && (r.status === "failed" || r.status === "partial") ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />让 Agent 排查
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{rec.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={5} />
|
||||
</div>
|
||||
) : rec.error || !r ? (
|
||||
<ErrorState error={rec.error} onRetry={() => void rec.refetch()} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
|
||||
<StatusBadge>
|
||||
{TRIGGER_LABEL[r.trigger]}
|
||||
{r.trigger === "agent" ? "(计为自动)" : ""}
|
||||
</StatusBadge>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped}
|
||||
</span>
|
||||
</div>
|
||||
{r.reason ? (
|
||||
<p
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-caption break-words",
|
||||
r.status === "failed"
|
||||
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||
: "border-warning/40 bg-warning/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{reasonText(r.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<PathBlock label="整理前" p={r.source} />
|
||||
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
|
||||
<PathBlock label="整理后" p={r.target} />
|
||||
</div>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "开始", value: formatDateTime(r.startedAt) },
|
||||
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
||||
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
|
||||
{ label: "触发者", value: `${r.actor.type}${r.actor.id ? ` · ${r.actor.id}` : ""}` },
|
||||
{
|
||||
label: "外部身份",
|
||||
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
|
||||
mono: true,
|
||||
},
|
||||
{ label: "变更游标", value: r.changeSeq, mono: true },
|
||||
{ label: "记录 ID", value: r.id, mono: true, wide: true },
|
||||
{ label: "任务 ID", value: r.taskId, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
{r.media ? (
|
||||
<Link
|
||||
to={`/console/library/${r.media.entityId}`}
|
||||
className="inline-block text-caption underline underline-offset-4"
|
||||
>
|
||||
查看作品
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="pg-slab overflow-hidden rounded-xl">
|
||||
<h2 className="px-4 pt-4 pb-2 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
文件明细({r.files?.length ?? 0})
|
||||
</h2>
|
||||
{r.files?.length ? (
|
||||
<ul className="divide-y divide-border/60 border-t border-border/60">
|
||||
{r.files.map((f) => (
|
||||
<FileRow key={f.id} f={f} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 pb-4 text-caption text-muted-foreground">
|
||||
{r.status === "queued" ? "排队中,尚未生成文件明细。" : "没有文件级结果。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,171 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { FileStack, FolderInput, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecords } from "~/api/records.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { OrganizeDialog } from "~/components/downloads/organize-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const ACTION_LABEL = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
rename: "重命名",
|
||||
relocate: "目录调整",
|
||||
hardlink: "硬链接",
|
||||
} as const;
|
||||
|
||||
function Counts({ r }: { r: PublicOrganizationRecord }) {
|
||||
const c = r.counts;
|
||||
return (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
<span className="text-foreground">
|
||||
{c.succeeded}/{c.total}
|
||||
</span>
|
||||
{c.failed ? <span className="text-destructive"> · 失败 {c.failed}</span> : null}
|
||||
{c.skipped ? <span className="text-muted-foreground"> · 跳过 {c.skipped}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
|
||||
{
|
||||
key: "media",
|
||||
header: "作品 / 目标",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="truncate text-foreground">
|
||||
{r.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : "未关联作品"}
|
||||
{r.media?.seasonNumber != null
|
||||
? ` S${String(r.media.seasonNumber).padStart(2, "0")}${r.media.episodeNumber != null ? `E${String(r.media.episodeNumber).padStart(2, "0")}` : ""}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={r.target.path ?? undefined}>
|
||||
{r.target.path ?? r.source.path ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
{r.reason ? (
|
||||
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{r.reason.message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "action", header: "动作", hideBelow: "sm", cell: (r) => ACTION_LABEL[r.action] },
|
||||
{ key: "files", header: "文件", hideBelow: "md", cell: (r) => <Counts r={r} /> },
|
||||
{ key: "trigger", header: "来源", hideBelow: "lg", cell: (r) => TRIGGER_LABEL[r.trigger] },
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{relTime(r.finishedAt ?? r.startedAt ?? r.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function OrganizePage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [status, setStatus] = useState("");
|
||||
const [trigger, setTrigger] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const entityId = params.get("mediaEntityId") ?? undefined;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQ(qInput.trim()), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [qInput]);
|
||||
const records = useRecords({
|
||||
status: (status || undefined) as OrganizationStatus | undefined,
|
||||
trigger: (trigger || undefined) as OrganizationTrigger | undefined,
|
||||
mediaEntityId: entityId,
|
||||
q: q || undefined,
|
||||
});
|
||||
useSse([EventType.organizationChanged], () => void qc.invalidateQueries({ queryKey: ["records"] }));
|
||||
const rows = records.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = !!(status || trigger || q || entityId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
description="Copy/Move 入库前后路径、动作与逐文件结果;自动与手动整理产生同格式记录。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOrganizing(true)}>
|
||||
<FolderInput />
|
||||
手动整理
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "搜索作品或路径" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setStatus("");
|
||||
setTrigger("");
|
||||
setQInput("");
|
||||
setParams({});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={trigger}
|
||||
onChange={setTrigger}
|
||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
{entityId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
||||
只看该作品
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={records.isPending}
|
||||
error={records.error}
|
||||
onRetry={() => void records.refetch()}
|
||||
onRowClick={(r) => navigate(`/console/organize/${r.id}`)}
|
||||
hasMore={records.hasNextPage}
|
||||
onLoadMore={() => void records.fetchNextPage()}
|
||||
loadingMore={records.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: FileStack,
|
||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: "下载完成后自动整理,或点“手动整理”选择文件发起。",
|
||||
}}
|
||||
/>
|
||||
{organizing ? <OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,405 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowLeft, Bot, Camera, Globe, Radar, SearchX, TriangleAlert } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useInventory } from "~/api/inventory.ts";
|
||||
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
|
||||
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||
import {
|
||||
accountSummary,
|
||||
categoryLabel,
|
||||
circuitLabel,
|
||||
hrOf,
|
||||
isFree,
|
||||
relTime,
|
||||
SITE_HEALTH,
|
||||
} from "~/components/workbench/release-fields.ts";
|
||||
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const PROMOS = [
|
||||
{ value: "free", label: "免费" },
|
||||
{ value: "2xfree", label: "2x 免费" },
|
||||
{ value: "50%", label: "50%" },
|
||||
{ value: "30%", label: "30%" },
|
||||
{ value: "2x", label: "2x 上传" },
|
||||
];
|
||||
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
|
||||
{ value: "resolved", label: "已识别" },
|
||||
{ value: "unresolved", label: "未识别" },
|
||||
{ value: "ambiguous", label: "有歧义" },
|
||||
{ value: "unidentifiable", label: "无法识别" },
|
||||
];
|
||||
const SIZE = [
|
||||
{ value: "lt5", label: "< 5 GB" },
|
||||
{ value: "5to20", label: "5–20 GB" },
|
||||
{ value: "20to60", label: "20–60 GB" },
|
||||
{ value: "gt60", label: "> 60 GB" },
|
||||
];
|
||||
const AGE = [
|
||||
{ value: "1", label: "24 小时内" },
|
||||
{ value: "7", label: "7 天内" },
|
||||
{ value: "30", label: "30 天内" },
|
||||
];
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** 服务端不支持的维度(H&R/做种/大小/上传时间)在已加载结果里筛选,界面上写明 */
|
||||
type LocalFilters = { hr: string; seeding: string; size: string; age: string };
|
||||
|
||||
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
|
||||
if (f.hr === "no" && hrOf(r) !== "no") return false;
|
||||
if (f.hr === "yes" && hrOf(r) !== "yes") return false;
|
||||
if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false;
|
||||
if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false;
|
||||
if (f.size) {
|
||||
const s = r.sizeBytes;
|
||||
if (s === null) return false;
|
||||
if (f.size === "lt5" && s >= 5 * GB) return false;
|
||||
if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false;
|
||||
if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false;
|
||||
if (f.size === "gt60" && s < 60 * GB) return false;
|
||||
}
|
||||
if (f.age) {
|
||||
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
|
||||
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, ms = 400) {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function SiteWorkbenchPage() {
|
||||
const { siteId = "" } = useParams();
|
||||
const site = useSite(siteId);
|
||||
const accounts = useSiteAccounts(siteId);
|
||||
const snap = useSnapshotSite();
|
||||
|
||||
const [qInput, setQInput] = useState("");
|
||||
const q = useDebounced(qInput.trim());
|
||||
const [category, setCategory] = useState("");
|
||||
const [promotion, setPromotion] = useState("");
|
||||
const [resolution, setResolution] = useState("");
|
||||
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
|
||||
const [live, setLive] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const filters: ReleaseFilters = {
|
||||
q: q || undefined,
|
||||
category: category || undefined,
|
||||
promotion: promotion || undefined,
|
||||
resolution: (resolution || undefined) as ResolutionStatus | undefined,
|
||||
};
|
||||
const releases = useSiteReleases(siteId, filters, live);
|
||||
// 实时补查只对当前搜索生效一次
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
|
||||
useEffect(() => setLive(false), [q, category, promotion, resolution]);
|
||||
|
||||
const pages = releases.data?.pages ?? [];
|
||||
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
|
||||
const coverage = pages[0]?.coverage;
|
||||
const now = Date.now();
|
||||
const rows = all.filter((r) => localMatch(r, local, now));
|
||||
const localActive = Object.values(local).some(Boolean);
|
||||
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
|
||||
const categories = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
|
||||
if (category && !m.has(category)) m.set(category, category);
|
||||
return [...m].map(([value, label]) => ({ value, label }));
|
||||
}, [all, category]);
|
||||
|
||||
const s = site.data;
|
||||
const refreshFailed = !!releases.error && !!releases.data;
|
||||
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
|
||||
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
|
||||
const selected = all.find((r) => r.id === openId) ?? null;
|
||||
const freeCount = all.filter(isFree).length;
|
||||
|
||||
const reset = () => {
|
||||
setQInput("");
|
||||
setCategory("");
|
||||
setPromotion("");
|
||||
setResolution("");
|
||||
setLocal({ hr: "", seeding: "", size: "", age: "" });
|
||||
};
|
||||
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
|
||||
|
||||
if (site.error && !s) return <ErrorState error={site.error} onRetry={() => void site.refetch()} />;
|
||||
|
||||
const health = s ? SITE_HEALTH[s.healthStatus] : null;
|
||||
const account = accountSummary(accounts.data?.items ?? []);
|
||||
const circuit = s ? circuitLabel(s) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={s?.name ?? "站点工作台"}
|
||||
description={
|
||||
s ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{health ? <StatusBadge tone={health.tone}>{health.label}</StatusBadge> : null}
|
||||
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
|
||||
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只展示历史数据</StatusBadge> : null}
|
||||
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||
<span className="text-meta text-muted-foreground">
|
||||
快照 {relTime(coverage?.lastSnapshotAt ?? s.lastSnapshotAt)}
|
||||
</span>
|
||||
{snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
|
||||
</span>
|
||||
) : (
|
||||
"加载中…"
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/sites">
|
||||
<ArrowLeft />
|
||||
站点
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
|
||||
>
|
||||
<Bot />问 Agent
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="organized">
|
||||
<TabsList>
|
||||
<TabsTrigger value="organized">整理视图</TabsTrigger>
|
||||
<TabsTrigger value="origin">原站视图</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="organized">
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
|
||||
onReset={anyFilter ? reset : undefined}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!s?.enabled || snap.isPending}
|
||||
title="手动快照(受站点限流)"
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: siteId, maxPages: 2 },
|
||||
{
|
||||
onSuccess: () => toast.success("已排队快照,完成后刷新列表"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
快照
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!q || !s?.enabled || (live && releases.isFetching)}
|
||||
title={q ? "绕过本地快照,向站点实时搜索当前关键词" : "先输入搜索词"}
|
||||
onClick={() => {
|
||||
if (live) void releases.refetch();
|
||||
else setLive(true);
|
||||
}}
|
||||
>
|
||||
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
|
||||
实时补查
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
|
||||
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
|
||||
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
|
||||
<FilterSelect
|
||||
label="H&R"
|
||||
value={local.hr}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, hr: v }))}
|
||||
options={[
|
||||
{ value: "no", label: "无 H&R" },
|
||||
{ value: "yes", label: "有 H&R" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="做种"
|
||||
value={local.seeding}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, seeding: v }))}
|
||||
options={[
|
||||
{ value: "alive", label: "有人做种" },
|
||||
{ value: "hot", label: "≥ 20 做种" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="大小"
|
||||
value={local.size}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, size: v }))}
|
||||
options={SIZE}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="上传"
|
||||
value={local.age}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
|
||||
options={AGE}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
{coverage || refreshFailed ? (
|
||||
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
|
||||
) : null}
|
||||
|
||||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
{releases.isPending ? (
|
||||
<LoadingState rows={6} className="p-4" />
|
||||
) : releases.error && !releases.data ? (
|
||||
<ErrorState
|
||||
plain
|
||||
title="读取站点资源失败"
|
||||
error={releases.error}
|
||||
onRetry={() => void releases.refetch()}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={all.length ? SearchX : Globe}
|
||||
title={
|
||||
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
|
||||
}
|
||||
description={
|
||||
all.length
|
||||
? "H&R / 做种 / 大小 / 上传时间只在已加载结果中筛选,可以加载更多或放宽条件。"
|
||||
: coverage?.liveError
|
||||
? `实时补查未成功(${coverage.liveError}),这不代表站点没有该资源。`
|
||||
: q
|
||||
? "可以点“实时补查”向站点直接搜索。"
|
||||
: "点“快照”抓取站点列表;快照受站点限流。"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
|
||||
<span>
|
||||
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
|
||||
</span>
|
||||
{freeCount ? <span>其中免费 {freeCount}</span> : null}
|
||||
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.map((r) => (
|
||||
<ReleaseRow
|
||||
key={r.id}
|
||||
r={r}
|
||||
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
|
||||
inventoryLoading={inventory.isLoading}
|
||||
staleBefore={staleBefore}
|
||||
selected={openId === r.id}
|
||||
onOpen={() => setOpenId(r.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{releases.hasNextPage ? (
|
||||
<div className="flex justify-center border-t border-border/60 px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void releases.fetchNextPage()}
|
||||
disabled={releases.isFetchingNextPage}
|
||||
>
|
||||
{releases.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="origin">
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title="原站视图在 M2 提供"
|
||||
description={
|
||||
<>
|
||||
原站视图会接入后端浏览器会话(camofox-browser),在同一会话里查看真实页面、人工登录或处理验证,并与 Agent
|
||||
交接控制权。首版只提供整理视图;需要看原站时,整理视图的详情里保留了资源的站点 ID 与来源时间。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ReleaseDetailSheet
|
||||
siteId={siteId}
|
||||
release={selected}
|
||||
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageBanner({
|
||||
coverage,
|
||||
refreshError,
|
||||
}: {
|
||||
coverage: import("@umt/contracts").WorkbenchCoverage | undefined;
|
||||
refreshError: unknown;
|
||||
}) {
|
||||
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
|
||||
if (refreshError)
|
||||
notes.push({ tone: "warning", text: `刷新失败,保留上次数据并标为过期:${errorMessage(refreshError)}` });
|
||||
if (coverage) {
|
||||
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只展示历史数据" });
|
||||
if (coverage.stale)
|
||||
notes.push({
|
||||
tone: "warning",
|
||||
text: coverage.lastSnapshotAt
|
||||
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)}),列表为既有数据`
|
||||
: "从未快照,列表只来自 Feed 观察与实时补查",
|
||||
});
|
||||
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError};已保留本地数据` });
|
||||
else if (coverage.live)
|
||||
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一实时搜索" : "已实时补查站点" });
|
||||
}
|
||||
if (!notes.length) return null;
|
||||
return (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{notes.map((n) => (
|
||||
<p
|
||||
key={n.text}
|
||||
className={
|
||||
n.tone === "warning"
|
||||
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
: n.tone === "success"
|
||||
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
|
||||
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span className="min-w-0 break-words">{n.text}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,159 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { Site, SiteAccount } from "@umt/contracts";
|
||||
import { ArrowRight, Camera, Globe, RefreshCw } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const ADAPTER_LABEL: Record<Site["adapter"], string> = {
|
||||
nexusphp: "NexusPHP",
|
||||
mteam_api: "M-Team API",
|
||||
api: "站点 API",
|
||||
generic_rss: "通用 RSS",
|
||||
};
|
||||
|
||||
function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
|
||||
const toggle = useToggleSite();
|
||||
const snap = useSnapshotSite();
|
||||
const health = SITE_HEALTH[site.healthStatus];
|
||||
const account = accountSummary(accounts);
|
||||
const stale = site.snapshotEnabled && snapshotStale(site);
|
||||
const circuit = circuitLabel(site);
|
||||
|
||||
return (
|
||||
<div className="pg-slab flex min-w-0 flex-col rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/console/sites/${site.id}`}
|
||||
className="block truncate text-lead font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{site.key} · {ADAPTER_LABEL[site.adapter]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
|
||||
<label htmlFor={`site-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label>
|
||||
<Switch
|
||||
id={`site-${site.id}`}
|
||||
checked={site.enabled}
|
||||
disabled={toggle.isPending}
|
||||
aria-label={site.enabled ? "停用站点" : "启用站点"}
|
||||
onCheckedChange={(v) =>
|
||||
toggle.mutate(
|
||||
{ id: site.id, enabled: v },
|
||||
{
|
||||
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name},自动访问停止`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
|
||||
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
|
||||
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">最近快照</dt>
|
||||
<dd className="flex flex-wrap items-center gap-1.5 text-foreground">
|
||||
{site.snapshotEnabled ? relTime(site.lastSnapshotAt) : "未开启快照"}
|
||||
{stale ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">快照周期</dt>
|
||||
<dd className="text-foreground">
|
||||
{site.snapshotIntervalSec ? `${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!site.enabled || snap.isPending}
|
||||
title={site.enabled ? "立即快照(受站点限流)" : "站点已停用"}
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: site.id },
|
||||
{
|
||||
onSuccess: () => toast.success("已排队快照,完成后自动刷新"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
快照
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/console/sites/${site.id}`}>
|
||||
打开工作台
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SitesPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const bySite = new Map<string, SiteAccount[]>();
|
||||
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="PT 站点工作台"
|
||||
description="按统一格式浏览站点资源,并叠加本地库存;下载走与自动收录相同的提交流程。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
|
||||
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{sites.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={4} />
|
||||
</div>
|
||||
) : sites.error && !sites.data ? (
|
||||
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
|
||||
) : !sites.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title="还没有登记站点"
|
||||
description="在“来源 / Feed / 快照”或系统设置里登记站点与账号后,这里会列出可浏览的站点。"
|
||||
action={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/console/sources">去来源页</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{sites.data.items.map((s) => (
|
||||
<SiteCard key={s.id} site={s} accounts={bySite.get(s.id) ?? []} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,320 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { FeedHealth, FeedSource } from "@umt/contracts";
|
||||
import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const HEALTH: Record<FeedHealth, { label: string; tone: StatusTone }> = {
|
||||
unknown: { label: "未抓取", tone: "neutral" },
|
||||
healthy: { label: "健康", tone: "success" },
|
||||
degraded: { label: "降级", tone: "warning" },
|
||||
failing: { label: "失败", tone: "destructive" },
|
||||
circuit_open: { label: "熔断", tone: "destructive" },
|
||||
disabled: { label: "已停用", tone: "neutral" },
|
||||
};
|
||||
|
||||
const lastErrorText = (f: FeedSource) =>
|
||||
f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null;
|
||||
|
||||
/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */
|
||||
function cursorSummary(f: FeedSource): string {
|
||||
const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined;
|
||||
if (!w) return "尚无游标";
|
||||
const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null;
|
||||
return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`;
|
||||
}
|
||||
|
||||
function FeedActions({ f }: { f: FeedSource }) {
|
||||
const fetchNow = useFetchFeedNow();
|
||||
const action = useFeedAction();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Switch
|
||||
checked={f.enabled}
|
||||
disabled={action.isPending}
|
||||
aria-label={f.enabled ? "停用" : "启用"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(v) =>
|
||||
action.mutate(
|
||||
{ id: f.id, action: v ? "enable" : "disable" },
|
||||
{ onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="立即抓取(仍受站点限流)"
|
||||
aria-label="立即抓取"
|
||||
disabled={!f.enabled || fetchNow.isPending}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fetchNow.mutate(f.id, {
|
||||
onSuccess: () => toast.success("已排队抓取"),
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Zap />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SourcesPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [health, setHealth] = useState("");
|
||||
const [siteFilter, setSiteFilter] = useState("");
|
||||
const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined });
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const [editing, setEditing] = useState<FeedSource | null | "new">(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const action = useFeedAction();
|
||||
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
|
||||
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
|
||||
const open = feeds.data?.items.find((f) => f.id === openId) ?? null;
|
||||
|
||||
const columns: DataColumn<FeedSource>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (f) => (
|
||||
<div className="min-w-0 max-w-xs">
|
||||
<p className="truncate font-medium text-foreground">{f.name}</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
|
||||
{f.urlDisplay}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
header: "健康",
|
||||
cell: (f) => {
|
||||
const gap = feedGapHint(f);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<StatusBadge tone={HEALTH[f.healthStatus].tone}>{HEALTH[f.healthStatus].label}</StatusBadge>
|
||||
{gap ? <StatusBadge tone="warning">疑似缺口</StatusBadge> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] },
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 账号",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => (
|
||||
<span className="text-muted-foreground">
|
||||
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
|
||||
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "poll",
|
||||
header: "轮询",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => <span className="whitespace-nowrap">{Math.round(f.pollIntervalSec / 60)} 分钟</span>,
|
||||
},
|
||||
{
|
||||
key: "success",
|
||||
header: "最后成功",
|
||||
hideBelow: "sm",
|
||||
cell: (f) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<p className="text-foreground">{relTime(f.lastSuccessAt)}</p>
|
||||
{f.consecutiveFailures ? (
|
||||
<p className="text-meta text-destructive">连续失败 {f.consecutiveFailures}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "ops", header: "", align: "right", cell: (f) => <FeedActions f={f} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="来源 / Feed / 快照"
|
||||
description="RSS 与站点快照的抓取状态、新鲜度与健康;站点不可访问时不会把零条目当作“没有更新”。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
新建 Feed
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Tabs defaultValue="feeds">
|
||||
<TabsList>
|
||||
<TabsTrigger value="feeds">Feed</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">站点快照</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="feeds">
|
||||
<FilterBar
|
||||
onReset={
|
||||
health || siteFilter
|
||||
? () => {
|
||||
setHealth("");
|
||||
setSiteFilter("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
|
||||
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="健康"
|
||||
value={health}
|
||||
onChange={setHealth}
|
||||
options={Object.entries(HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="站点"
|
||||
value={siteFilter}
|
||||
onChange={setSiteFilter}
|
||||
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={feeds.data?.items}
|
||||
rowKey={(f) => f.id}
|
||||
loading={feeds.isPending}
|
||||
error={feeds.error}
|
||||
onRetry={() => void feeds.refetch()}
|
||||
onRowClick={(f) => setOpenId(f.id)}
|
||||
selectedKey={openId}
|
||||
empty={{
|
||||
icon: Rss,
|
||||
title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed",
|
||||
description: "新建 RSS Feed 后按轮询策略抓取,新条目进入识别与收录评估。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="snapshots">
|
||||
{sites.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : sites.error ? (
|
||||
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
|
||||
) : !sites.data.items.length ? (
|
||||
<EmptyState title="还没有登记站点" description="登记站点后可以在这里看快照覆盖范围与新鲜度。" />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{sites.data.items.map((s) => (
|
||||
<SnapshotPanel key={s.id} site={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DetailSheet
|
||||
open={!!open}
|
||||
onOpenChange={(o) => !o && setOpenId(null)}
|
||||
title={open?.name ?? ""}
|
||||
description={open ? <span className="font-mono break-all">{open.urlDisplay}</span> : undefined}
|
||||
footer={
|
||||
open ? (
|
||||
<>
|
||||
{open.healthStatus === "circuit_open" || open.healthStatus === "failing" || open.backoffUntil ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={action.isPending}
|
||||
onClick={() =>
|
||||
action.mutate(
|
||||
{ id: open.id, action: "reset" },
|
||||
{
|
||||
onSuccess: () => toast.success("已关闭熔断并清空退避"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw />
|
||||
复位熔断
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => setEditing(open)}>
|
||||
编辑
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone={HEALTH[open.healthStatus].tone}>{HEALTH[open.healthStatus].label}</StatusBadge>
|
||||
<StatusBadge>{open.enabled ? "已启用" : "已停用"}</StatusBadge>
|
||||
<StatusBadge>{PURPOSE_LABEL[open.purpose]}</StatusBadge>
|
||||
</div>
|
||||
{feedGapHint(open) ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
{feedGapHint(open)}
|
||||
</p>
|
||||
) : null}
|
||||
{lastErrorText(open) ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption break-words text-destructive">
|
||||
{lastErrorText(open)}
|
||||
</p>
|
||||
) : null}
|
||||
<DetailSection title="抓取状态">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "最后成功", value: formatDateTime(open.lastSuccessAt) },
|
||||
{ label: "最后抓取", value: formatDateTime(open.lastFetchAt) },
|
||||
{ label: "下次抓取", value: formatDateTime(open.nextFetchAt) },
|
||||
{ label: "退避至", value: formatDateTime(open.backoffUntil) },
|
||||
{ label: "连续失败", value: open.consecutiveFailures },
|
||||
{ label: "轮询间隔", value: `${Math.round(open.pollIntervalSec / 60)} 分钟` },
|
||||
{ label: "游标", value: cursorSummary(open) },
|
||||
{ label: "解析器", value: open.parserVersion, mono: true },
|
||||
{ label: "ETag", value: open.etag, mono: true },
|
||||
{ label: "Last-Modified", value: open.lastModified, mono: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<JsonView label="过滤条件" value={open.filter} />
|
||||
<JsonView label="游标原文" value={open.cursor} />
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
|
||||
{editing ? (
|
||||
<FeedFormDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
feed={editing === "new" ? null : editing}
|
||||
sites={sites.data?.items ?? []}
|
||||
accounts={accounts.data?.items ?? []}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,38 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useDownloadTask } from "~/api/downloads.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
export default function DownloadTaskPage() {
|
||||
const { id = "" } = useParams();
|
||||
const task = useDownloadTask(id);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={task.data?.name ?? "下载任务"}
|
||||
description="下载 → 整理 → 核验的业务时间线与子任务"
|
||||
className="[&_h1]:break-all"
|
||||
actions={
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/tasks">
|
||||
<ArrowLeft />
|
||||
下载与任务
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="pg-slab rounded-xl p-4 md:p-5">
|
||||
{task.isPending ? (
|
||||
<LoadingState rows={5} />
|
||||
) : task.error ? (
|
||||
<ErrorState plain error={task.error} onRetry={() => void task.refetch()} />
|
||||
) : (
|
||||
<DownloadDetail task={task.data} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,308 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { AcquisitionPlan, DownloadStatus, DownloadTask, PlanStatus } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { Download, ListChecks, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import {
|
||||
DOWNLOAD_STATUS_LABEL,
|
||||
PLAN_STATUS_LABEL,
|
||||
useDownloaderNames,
|
||||
useDownloadTasks,
|
||||
usePlans,
|
||||
} from "~/api/downloads.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
||||
import {
|
||||
DownloadDetail,
|
||||
DownloadStatusBadges,
|
||||
PauseResume,
|
||||
ProgressLine,
|
||||
} from "~/components/downloads/download-detail.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const;
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function TasksPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [status, setStatus] = useState("");
|
||||
const [managed, setManaged] = useState("");
|
||||
const [planStatus, setPlanStatus] = useState("");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [openPlan, setOpenPlan] = useState<AcquisitionPlan | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const downloads = useDownloadTasks({
|
||||
status: (status || undefined) as DownloadStatus | undefined,
|
||||
managed: (managed || undefined) as "true" | "false" | undefined,
|
||||
});
|
||||
const plans = usePlans((planStatus || undefined) as PlanStatus | undefined);
|
||||
const names = useDownloaderNames();
|
||||
|
||||
// 下载进度/状态、阈值事件到达时刷新列表(SSE 为主,轮询兜底)
|
||||
useSse(
|
||||
[
|
||||
EventType.downloadProgress,
|
||||
EventType.downloadStatusChanged,
|
||||
EventType.downloadSubmitted,
|
||||
EventType.downloadThreshold,
|
||||
],
|
||||
() => void qc.invalidateQueries({ queryKey: ["downloads"] }),
|
||||
);
|
||||
|
||||
const rows = downloads.data?.pages.flatMap((p) => p.items);
|
||||
const planRows = plans.data?.pages.flatMap((p) => p.items);
|
||||
const open = rows?.find((t) => t.id === openId) ?? null;
|
||||
const linkedDownload = openPlan ? rows?.find((t) => t.acquisitionPlanId === openPlan.id) : undefined;
|
||||
|
||||
const columns: DataColumn<DownloadTask>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "任务",
|
||||
cell: (t) => (
|
||||
<div className="min-w-0 max-w-sm">
|
||||
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground">
|
||||
{names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (t) => <DownloadStatusBadges t={t} /> },
|
||||
{ key: "progress", header: "进度", hideBelow: "sm", cell: (t) => <ProgressLine t={t} /> },
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
hideBelow: "lg",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "更新",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">{relTime(t.lastSyncedAt ?? t.updatedAt)}</span>
|
||||
),
|
||||
},
|
||||
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
||||
];
|
||||
|
||||
const planColumns: DataColumn<AcquisitionPlan>[] = [
|
||||
{
|
||||
key: "purpose",
|
||||
header: "计划",
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground">
|
||||
{PURPOSE[p.purpose]} · 优先级 {p.priority}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground">{p.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (p) => (
|
||||
<StatusBadge
|
||||
tone={
|
||||
p.status === "failed"
|
||||
? "destructive"
|
||||
: p.status === "completed"
|
||||
? "success"
|
||||
: p.status === "expired"
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{PLAN_STATUS_LABEL[p.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reserved",
|
||||
header: "预留空间",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (p) => <span className="tabular-nums">{formatBytes(p.reservedBytes, 1)}</span>,
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
header: "说明",
|
||||
hideBelow: "lg",
|
||||
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{p.statusReason ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "创建",
|
||||
hideBelow: "sm",
|
||||
cell: (p) => <span className="whitespace-nowrap text-muted-foreground">{relTime(p.createdAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="下载与任务"
|
||||
description="下载、整理、核验串成一条业务时间线;阈值暂停不能手动绕过,无进展只记录不自动换源。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
|
||||
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CacheVolumes />
|
||||
<Tabs defaultValue="downloads">
|
||||
<TabsList>
|
||||
<TabsTrigger value="downloads">下载任务</TabsTrigger>
|
||||
<TabsTrigger value="plans">下载计划</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="downloads">
|
||||
<FilterBar
|
||||
onReset={
|
||||
status || managed
|
||||
? () => {
|
||||
setStatus("");
|
||||
setManaged("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="纳管"
|
||||
value={managed}
|
||||
onChange={setManaged}
|
||||
options={[
|
||||
{ value: "true", label: "纳管任务" },
|
||||
{ value: "false", label: "未纳管" },
|
||||
]}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(t) => t.id}
|
||||
loading={downloads.isPending}
|
||||
error={downloads.error}
|
||||
onRetry={() => void downloads.refetch()}
|
||||
onRowClick={(t) => setOpenId(t.id)}
|
||||
selectedKey={openId}
|
||||
hasMore={downloads.hasNextPage}
|
||||
onLoadMore={() => void downloads.fetchNextPage()}
|
||||
loadingMore={downloads.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: Download,
|
||||
title: status || managed ? "没有符合筛选的下载" : "还没有下载任务",
|
||||
description: "自动收录或在站点工作台手动下载后,任务会出现在这里。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="plans">
|
||||
<FilterBar onReset={planStatus ? () => setPlanStatus("") : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={planStatus}
|
||||
onChange={setPlanStatus}
|
||||
options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={planColumns}
|
||||
rows={planRows}
|
||||
rowKey={(p) => p.id}
|
||||
loading={plans.isPending}
|
||||
error={plans.error}
|
||||
onRetry={() => void plans.refetch()}
|
||||
onRowClick={setOpenPlan}
|
||||
selectedKey={openPlan?.id}
|
||||
hasMore={plans.hasNextPage}
|
||||
onLoadMore={() => void plans.fetchNextPage()}
|
||||
loadingMore={plans.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: ListChecks,
|
||||
title: "还没有下载计划",
|
||||
description: "规则评估选中候选后生成计划并预留 Slot 与预算。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DetailSheet
|
||||
open={!!open}
|
||||
onOpenChange={(o) => !o && setOpenId(null)}
|
||||
width="lg"
|
||||
title={open?.name ?? ""}
|
||||
footer={
|
||||
open ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/tasks/${open.id}`}>在独立页面打开</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? <DownloadDetail task={open} /> : null}
|
||||
</DetailSheet>
|
||||
|
||||
<DetailSheet
|
||||
open={!!openPlan}
|
||||
onOpenChange={(o) => !o && setOpenPlan(null)}
|
||||
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
|
||||
description={openPlan?.statusReason ?? undefined}
|
||||
footer={
|
||||
linkedDownload ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpenPlan(null);
|
||||
navigate(`/console/tasks/${linkedDownload.id}`);
|
||||
}}
|
||||
>
|
||||
查看下载任务
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{openPlan ? (
|
||||
<>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: PLAN_STATUS_LABEL[openPlan.status] },
|
||||
{ label: "优先级", value: openPlan.priority },
|
||||
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
|
||||
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
|
||||
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
|
||||
{ label: "Slot", value: openPlan.slotId, mono: true },
|
||||
{ label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
<DetailSection title="评估依据">
|
||||
<JsonView value={openPlan.evaluation} />
|
||||
</DetailSection>
|
||||
<JsonView label="预算预留" value={openPlan.budgetReservation} />
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user