From 909cd2ad49877b1d929e64d89e030eb257b702df Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 02:46:47 +0800 Subject: [PATCH] feat(web): regroup console nav into 7 groups, split downloads/plans/tasks, add legacy redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar: 概览/资源/订阅/下载/整理/Agent/系统, 18 items (+ 定时任务); single active item via findNavItem; 待处理 badge; collapsed rail groups; mobile drawer shares NAV_GROUPS. - New pages: /downloads (queue + cache watermark header), /downloads/plans, /downloads/:id, /tasks (business tasks only) + /tasks/:id detail, /rules. - Legacy links: ?tab= on tasks/subscriptions/sources redirect via clientLoader; /tasks/:downloadId falls back to /downloads/:id. - List filters in URL query; settingsPath() helper for settings deep links. - Copy per glossary (待处理, 收录策略, 引擎面板, 文件管理, 版本目标). Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/decisions.ts | 2 +- apps/web/app/api/tasks.ts | 2 +- apps/web/app/components/console/nav.test.ts | 63 +++- apps/web/app/components/console/nav.ts | 174 +++++++--- .../app/components/console/use-query-state.ts | 40 +++ .../components/downloads/business-tasks.tsx | 302 +++++++++++------ .../components/downloads/cache-volumes.tsx | 59 ++-- apps/web/app/components/files/dialogs.tsx | 8 +- apps/web/app/components/files/file-detail.tsx | 2 +- apps/web/app/components/files/file-list.tsx | 2 +- .../app/components/files/organize-wizard.tsx | 2 +- .../library/subscriptions-policy.tsx | 1 - .../components/workbench/download-dialog.tsx | 4 +- apps/web/app/routes.ts | 4 + apps/web/app/routes/console/downloads.$id.tsx | 41 +++ .../app/routes/console/downloads.plans.tsx | 146 +++++++++ apps/web/app/routes/console/downloads.tsx | 155 +++++++++ apps/web/app/routes/console/engine.$runId.tsx | 2 +- apps/web/app/routes/console/engine.tsx | 15 +- apps/web/app/routes/console/files.tsx | 4 +- apps/web/app/routes/console/home.tsx | 11 +- apps/web/app/routes/console/inbox.tsx | 4 +- apps/web/app/routes/console/layout.tsx | 114 ++++--- apps/web/app/routes/console/library.$id.tsx | 2 +- apps/web/app/routes/console/preferences.tsx | 20 +- apps/web/app/routes/console/redirects.ts | 48 +++ apps/web/app/routes/console/rules.tsx | 38 +++ apps/web/app/routes/console/subscriptions.tsx | 59 ++-- apps/web/app/routes/console/tasks.$id.tsx | 167 ++++++++-- apps/web/app/routes/console/tasks.tsx | 310 +----------------- apps/web/scripts/smoke.mjs | 55 +++- 31 files changed, 1247 insertions(+), 609 deletions(-) create mode 100644 apps/web/app/components/console/use-query-state.ts create mode 100644 apps/web/app/routes/console/downloads.$id.tsx create mode 100644 apps/web/app/routes/console/downloads.plans.tsx create mode 100644 apps/web/app/routes/console/downloads.tsx create mode 100644 apps/web/app/routes/console/redirects.ts create mode 100644 apps/web/app/routes/console/rules.tsx diff --git a/apps/web/app/api/decisions.ts b/apps/web/app/api/decisions.ts index 92e919a3..adf33f80 100644 --- a/apps/web/app/api/decisions.ts +++ b/apps/web/app/api/decisions.ts @@ -10,7 +10,7 @@ import { import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; -// 待处理中心:按作品与问题类型聚合(summary),条目带证据/建议/影响范围,resolve 选项来自 decision.options。 +// 待处理:按作品与问题类型聚合(summary),条目带证据/建议/影响范围,resolve 选项来自 decision.options。 export const decisionKeys = { all: ["decisions"] as const, diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index 2921b25e..227fc789 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -266,7 +266,7 @@ export function buildTimeline(input: { detail: [t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null, tone: TASK_STATUS_TONE[t.status], - ...(t.engineRunId ? { href: `/console/engine/${encodeURIComponent(t.engineRunId)}` } : {}), + href: `/console/tasks/${encodeURIComponent(t.id)}`, ongoing: ACTIVE_TASK.includes(t.status), }); } diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index c10af987..8294545e 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -1,23 +1,76 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts"; +import { LEGACY_REDIRECTS, legacyRedirect } from "../../routes/console/redirects.ts"; +import { consolePath, findNavItem, NAV_GROUPS, NAV_ITEMS, settingsPath } from "./nav.ts"; + +/** routes.ts 里登记的控制台路由(相对 /console) */ +const ROUTE_PATTERNS = [ + "", + ...[...readFileSync(new URL("../../routes.ts", import.meta.url), "utf8").matchAll(/route\("([^"*]+)",/g)].map( + (m) => m[1] as string, + ), +]; +const matchesRoute = (pathname: string) => { + const rest = pathname.replace(/^\/console\/?/, ""); + return ROUTE_PATTERNS.some((p) => new RegExp(`^${p.replace(/:[^/]+/g, "[^/]+")}$`).test(rest)); +}; describe("控制台导航", () => { - it("12 个工作区 + 定时任务 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => { - expect(NAV_ITEMS).toHaveLength(16); - expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(16); + it("7 组 18 项(另加系统组的定时任务),路径唯一且都在 /console 下", () => { + expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "下载", "整理", "Agent", "系统"]); + const core = NAV_ITEMS.filter((i) => i.to !== "/console/schedules"); + expect(core).toHaveLength(18); + expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); + for (const g of NAV_GROUPS) expect(g.items.length).toBeLessThanOrEqual(4); + }); + + it("每个导航项都有路由", () => { + for (const i of NAV_ITEMS) expect(matchesRoute(i.to), i.to).toBe(true); }); it("按最长前缀匹配当前项", () => { expect(findNavItem("/console")?.label).toBe("首页"); expect(findNavItem("/console/")?.label).toBe("首页"); expect(findNavItem("/console/library/abc")?.label).toBe("媒体库"); - expect(findNavItem("/console/engine/r1")?.label).toBe("工作流引擎"); + expect(findNavItem("/console/engine/r1")?.label).toBe("引擎面板"); + expect(findNavItem("/console/downloads")?.label).toBe("下载队列"); + expect(findNavItem("/console/downloads/plans")?.label).toBe("下载计划"); + expect(findNavItem("/console/downloads/abc")?.label).toBe("下载队列"); + expect(findNavItem("/console/tasks/abc")?.label).toBe("业务任务"); + expect(findNavItem("/console/sites/abc")?.label).toBe("站点工作台"); expect(findNavItem("/nope")).toBeUndefined(); }); it("consolePath 拼路径并编码", () => { expect(consolePath()).toBe("/console"); expect(consolePath("library", "a b")).toBe("/console/library/a%20b"); + expect(consolePath("downloads", "plans")).toBe("/console/downloads/plans"); + }); + + it("settingsPath 只认新分区名", () => { + expect(settingsPath("automation", "cache").startsWith("/console/settings")).toBe(true); + expect(settingsPath("agent").startsWith("/console/settings")).toBe(true); + }); +}); + +describe("旧链接重定向", () => { + it.each(LEGACY_REDIRECTS)("$from → $to", ({ from, to }) => { + const u = new URL(from, "http://x"); + expect(legacyRedirect(u.pathname, u.search)).toBe(to); + }); + + it("新地址都有路由,且不会再次跳转", () => { + for (const { to } of LEGACY_REDIRECTS) { + const u = new URL(to, "http://x"); + expect(matchesRoute(u.pathname), to).toBe(true); + if (u.pathname !== "/console/sites/abc") expect(legacyRedirect(u.pathname, u.search)).toBeNull(); + } + }); + + it("不相关的 query 不跳转", () => { + expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBeNull(); + expect(legacyRedirect("/console/subscriptions", "")).toBeNull(); + expect(legacyRedirect("/console/sources", "?health=failing")).toBeNull(); }); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 269dade3..dc1773ac 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -2,6 +2,7 @@ import { Activity, Bot, CalendarClock, + ClipboardList, Download, FileStack, Fingerprint, @@ -10,6 +11,8 @@ import { House, Inbox, Library, + ListChecks, + ListTodo, type LucideIcon, Rss, ScrollText, @@ -18,7 +21,14 @@ import { Timer, } from "lucide-react"; -export type NavItem = { to: string; label: string; icon: LucideIcon; description: string }; +export type NavItem = { + to: string; + label: string; + icon: LucideIcon; + description: string; + /** 侧栏角标的数据来源 */ + badge?: "decisions"; +}; export type NavGroup = { title: string; items: NavItem[] }; /** 控制台路由前缀 */ @@ -30,7 +40,10 @@ export function consolePath(...segments: (string | number)[]): string { return rest ? `${CONSOLE_BASE}/${rest}` : CONSOLE_BASE; } -/** 控制台 12 个工作区(任务书 §3.1 第 10 条 / REQ §13.1)+ 引擎面板与审计两个运维页 */ +/** + * 侧栏 7 组(docs/design/console-ia.md §3.2),按业务流程排:概览 → 资源 → 订阅 → 下载 → 整理 → Agent → 系统。 + * 移动端抽屉用同一份表。 + */ export const NAV_GROUPS: NavGroup[] = [ { title: "概览", @@ -39,89 +52,123 @@ export const NAV_GROUPS: NavGroup[] = [ to: "/console", label: "首页", icon: House, - description: "自动化概览、当前工作、需要我处理、缓存与保留、来源健康、最近产出", + description: "自动化概览、当前工作、待处理、缓存、来源健康、最近产出", }, { to: "/console/inbox", - label: "待处理中心", + label: "待处理", icon: Inbox, - description: "按作品与根因合并的待决定事项,Agent 已先行调查", + description: "按作品和问题类型合并的待处理事项", + badge: "decisions", }, ], }, { - title: "媒体", + title: "资源", items: [ - { to: "/console/library", label: "媒体库", icon: Library, description: "作品、内容单元、版本目标与实际路径" }, { - to: "/console/identity", - label: "身份与元数据", - icon: Fingerprint, - description: "外部身份映射、待识别池与元数据来源", + to: "/console/sites", + label: "站点工作台", + icon: Globe, + description: "站点、站点账号、快照与 Feed", }, { - to: "/console/organize", - label: "整理记录", - icon: FileStack, - description: "Copy/Move 入库前后路径与逐文件结果", - }, - { - to: "/console/files", - label: "文件", - icon: FolderTree, - description: "浏览已登记的库根与下载目录,改名、移动、删除与批量整理", + to: "/console/sources", + label: "来源健康", + icon: Rss, + description: "全部 Feed 与站点的抓取状态", }, ], }, { - title: "获取", + title: "订阅", items: [ { to: "/console/subscriptions", - label: "订阅与收录规则", + label: "订阅", icon: CalendarClock, - description: "单次收录与持续追踪,版本目标与预算", + description: "单次收录与持续追踪", + }, + { + to: "/console/rules", + label: "收录策略", + icon: ListChecks, + description: "新作品收录条件、默认版本目标与策略版本", }, { to: "/console/preferences", label: "偏好标签", icon: Tags, - description: "软偏好与硬规则,手动取消的偏好不会被自动加回", - }, - { - to: "/console/sites", - label: "PT 站点工作台", - icon: Globe, - description: "统一格式浏览、搜索站点资源并关联本地库存", - }, - { - to: "/console/sources", - label: "来源 / Feed / 快照", - icon: Rss, - description: "RSS 与站点快照的抓取状态、新鲜度与健康", - }, - { - to: "/console/tasks", - label: "下载与任务", - icon: Download, - description: "下载、整理、核验的业务时间线与子任务", + description: "软偏好影响排序,硬规则直接排除", }, ], }, { - title: "助手与系统", + title: "下载", + items: [ + { + to: "/console/downloads", + label: "下载队列", + icon: Download, + description: "下载任务与缓存水位", + }, + { + to: "/console/downloads/plans", + label: "下载计划", + icon: ClipboardList, + description: "选中候选后生成的计划与空间预留", + }, + { + to: "/console/tasks", + label: "业务任务", + icon: ListTodo, + description: "提交下载、核验入库、清理缓存等后台任务", + }, + ], + }, + { + title: "整理", + items: [ + { to: "/console/library", label: "媒体库", icon: Library, description: "作品、季 / 集、版本目标与实际路径" }, + { + to: "/console/organize", + label: "整理记录", + icon: FileStack, + description: "每次整理的前后路径与逐文件结果", + }, + { + to: "/console/files", + label: "文件管理", + icon: FolderTree, + description: "浏览登记目录,改名、移动、删除与整理", + }, + { + to: "/console/identity", + label: "身份与元数据", + icon: Fingerprint, + description: "外部身份、待识别资源与别名", + }, + ], + }, + { + title: "Agent", items: [ { to: "/console/agent", label: "Agent 会话", icon: Bot, - description: "用自然语言交代目标,Agent 在授权内执行并汇报", + description: "交代目标,Agent 在授权内执行并汇报", }, + ], + }, + { + title: "系统", + items: [ { to: "/console/settings", - label: "系统设置", + label: "设置", icon: Settings, - description: "下载器与媒体服务器实例、路径映射、API Key、Pi 上游与自动化开关", + description: "下载器、媒体服务器、库根、站点账号、Agent 上游与自动化", }, { to: "/console/schedules", @@ -131,9 +178,9 @@ export const NAV_GROUPS: NavGroup[] = [ }, { to: "/console/engine", - label: "工作流引擎", + label: "引擎面板", icon: Activity, - description: "持久任务引擎的运行记录、步骤尝试与信号(只读诊断,仅管理员)", + description: "排查用,业务进度看业务任务。仅管理员", }, { to: "/console/audit", @@ -147,6 +194,35 @@ export const NAV_GROUPS: NavGroup[] = [ export const NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items); +/** + * 设置页深链。IA-2 把设置改为 /console/settings/:section 之前,先落到现有 ?tab=; + * 调用方只认新分区名,切换时只改这里。 + */ +export type SettingsSection = + | "automation" + | "sites" + | "downloaders" + | "library" + | "media-servers" + | "metadata" + | "agent" + | "api-keys" + | "account"; +const SETTINGS_LEGACY_TAB: Partial> = { + automation: "automation", + sites: "site-credentials", + downloaders: "downloaders", + library: "library-roots", + "media-servers": "media-servers", + agent: "pi", + "api-keys": "api-keys", +}; +export function settingsPath(section: SettingsSection, anchor?: string): string { + // 缓存阈值现在在"缓存与入库"页签,IA-2 后归到 automation#cache + const tab = section === "automation" && anchor === "cache" ? "storage" : SETTINGS_LEGACY_TAB[section]; + return tab ? `${CONSOLE_BASE}/settings?tab=${tab}` : `${CONSOLE_BASE}/settings`; +} + /** 按路径找当前导航项(最长前缀匹配) */ export function findNavItem(pathname: string): NavItem | undefined { return ( diff --git a/apps/web/app/components/console/use-query-state.ts b/apps/web/app/components/console/use-query-state.ts new file mode 100644 index 00000000..e6c8c090 --- /dev/null +++ b/apps/web/app/components/console/use-query-state.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react"; +import { useSearchParams } from "react-router"; + +/** + * 列表页筛选状态写进 URL query(docs/design/console-ia.md §3.6),刷新和分享链接不丢筛选。 + * const f = useQueryState(["status", "managed"]); + * f.values.status; f.set("status", "paused"); f.reset(); + */ +export function useQueryState(keys: readonly K[]) { + const [params, setParams] = useSearchParams(); + const values = Object.fromEntries(keys.map((k) => [k, params.get(k) ?? ""])) as Record; + const set = useCallback( + (key: K, value: string) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + return next; + }, + { replace: true }, + ), + [setParams], + ); + const keyList = keys.join(","); + const reset = useCallback( + () => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + for (const k of keyList.split(",")) next.delete(k); + return next; + }, + { replace: true }, + ), + [setParams, keyList], + ); + const active = keys.some((k) => !!values[k]); + return { values, set, reset, active }; +} diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index 5eb92e62..e3e04083 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -6,13 +6,19 @@ import { Link } from "react-router"; import { toast } from "sonner"; import { apiMutate, apiParsed, cleanParams } from "~/api/parse.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 { KvList } from "~/components/console/kv-list.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { useQueryState } from "~/components/console/use-query-state.ts"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage, TTL_SECOND } from "~/lib/api.ts"; +import { formatDateTime } from "~/lib/utils.ts"; // 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。 +// 引擎运行只在详情里给链接,引擎面板在系统组。 const TaskPage = cursorPage(Task); @@ -42,34 +48,54 @@ export const TASK_STATUS: Record< dead: { label: "放弃", tone: "destructive" }, }; -const ACTIVE: TaskStatus[] = ["pending", "running", "waiting"]; +export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"]; -function subjectLink(t: Task): string | null { +export const SUBJECT_LABEL: Record = { + download_task: "下载任务", + acquisition_plan: "下载计划", + acquisition_slot: "版本目标", + organization_record: "整理记录", + organize_request: "整理请求", + media_entity: "作品", + media_asset: "资产", + source_release: "资源", + feed_source: "Feed", + site: "站点", + library_root: "库根", + cache_volume: "缓存卷", +}; + +/** 业务任务的对象 → 它的"家"页面 */ +export function subjectLink(t: Pick): string | null { if (!t.subjectId) return null; - if (t.subjectType === "download_task") return `/console/tasks/${t.subjectId}`; - if (t.subjectType === "organization_record") return `/console/organize/${t.subjectId}`; - if (t.subjectType === "media_entity") return `/console/library/${t.subjectId}`; - return null; + switch (t.subjectType) { + case "download_task": + return consolePath("downloads", t.subjectId); + case "acquisition_plan": + return consolePath("downloads", "plans"); + case "organization_record": + return consolePath("organize", t.subjectId); + case "media_entity": + return consolePath("library", t.subjectId); + case "site": + return consolePath("sites", t.subjectId); + case "feed_source": + return consolePath("sources"); + default: + return null; + } } -export function BusinessTasks() { - const [kind, setKind] = useState(""); - const [status, setStatus] = useState(""); +export function subjectText(t: Pick): string { + if (!t.subjectType) return "—"; + const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType; + return t.subjectId ? `${label} · ${t.subjectId.slice(0, 8)}` : label; +} + +/** 取消 / 重试 */ +export function useTaskAction() { const qc = useQueryClient(); - const list = useInfiniteQuery({ - queryKey: ["tasks", "list", kind, status], - initialPageParam: undefined as string | undefined, - placeholderData: keepPreviousData, - queryFn: ({ pageParam, signal }) => - apiParsed(TaskPage, "/api/v1/tasks", { - params: cleanParams({ kind, status, cursor: pageParam, limit: 50 }), - signal, - }), - getNextPageParam: (last) => last.nextCursor ?? undefined, - refetchInterval: (q) => - q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 5 * TTL_SECOND : false, - }); - const action = useMutation({ + return useMutation({ mutationFn: ({ id, op }: { id: string; op: "cancel" | "retry" }) => apiMutate(`/api/v1/tasks/:id/${op}`, { pathParams: { id }, @@ -81,27 +107,74 @@ export function BusinessTasks() { }, onError: (e) => toast.error(errorMessage(e)), }); +} + +/** 取消 / 重试按钮(按状态二选一或都不显示) */ +export function TaskActions({ t, size = "sm" }: { t: Task; size?: "sm" | "xs" }) { + const action = useTaskAction(); + if (ACTIVE_TASK.includes(t.status)) + return ( + + ); + if (t.status === "failed" || t.status === "dead") + return ( + + ); + return null; +} + +export function BusinessTasks() { + const f = useQueryState(["kind", "status"] as const); + const { kind, status } = f.values; + const [openId, setOpenId] = useState(null); + const list = useInfiniteQuery({ + queryKey: ["tasks", "list", kind, status], + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: ({ pageParam, signal }) => + apiParsed(TaskPage, "/api/v1/tasks", { + params: cleanParams({ kind, status, cursor: pageParam, limit: 50 }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + refetchInterval: (q) => + q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE_TASK.includes(t.status))) ? 5 * TTL_SECOND : false, + }); const rows = list.data?.pages.flatMap((p) => p.items); + const open = rows?.find((t) => t.id === openId) ?? null; const columns: DataColumn[] = [ { key: "kind", header: "任务", - cell: (t) => { - const href = subjectLink(t); - return ( -
-

{TASK_KIND_LABEL[t.kind] ?? t.kind}

- {href ? ( - - {t.subjectType} · {t.subjectId?.slice(0, 8)} - - ) : ( -

{t.subjectType ?? "—"}

- )} -
- ); - }, + cell: (t) => ( +
+

{TASK_KIND_LABEL[t.kind] ?? t.kind}

+

{subjectText(t)}

+
+ ), }, { key: "status", @@ -136,72 +209,22 @@ export function BusinessTasks() { hideBelow: "sm", cell: (t) => {relTime(t.createdAt)}, }, - { - key: "ops", - header: "", - align: "right", - cell: (t) => ( -
- {t.engineRunId ? ( - - ) : null} - {ACTIVE.includes(t.status) ? ( - - ) : null} - {t.status === "failed" || t.status === "dead" ? ( - - ) : null} -
- ), - }, + { key: "ops", header: "", align: "right", cell: (t) => }, ]; return ( <> - { - setKind(""); - setStatus(""); - } - : undefined - } - > + f.set("kind", v)} options={Object.entries(TASK_KIND_LABEL).map(([value, label]) => ({ value, label }))} /> f.set("status", v)} options={Object.entries(TASK_STATUS).map(([value, v]) => ({ value, label: v.label }))} /> @@ -212,15 +235,106 @@ export function BusinessTasks() { loading={list.isPending} error={list.error} onRetry={() => void list.refetch()} + onRowClick={(t) => setOpenId(t.id)} + selectedKey={openId} hasMore={list.hasNextPage} onLoadMore={() => void list.fetchNextPage()} loadingMore={list.isFetchingNextPage} empty={{ icon: ListTodo, - title: kind || status ? "没有符合筛选的任务" : "还没有业务任务", - description: "提交下载、核验入库、清理缓存、对账等后台任务会出现在这里。", + title: f.active ? "没有符合筛选的任务" : "还没有业务任务", + description: f.active ? undefined : "提交下载、核验入库、清理缓存、对账等后台任务会出现在这里。", }} /> + !o && setOpenId(null)} + title={open ? (TASK_KIND_LABEL[open.kind] ?? open.kind) : ""} + description={open ? : undefined} + footer={ + open ? ( + <> + + + + ) : null + } + > + {open ? : null} + ); } + +export function TaskStatusLine({ t }: { t: Task }) { + return ( + + {TASK_STATUS[t.status].label} + {t.waitReason ? {t.waitReason} : null} + + ); +} + +/** 概况 + 关联对象(抽屉与详情页共用) */ +export function TaskSummary({ t }: { t: Task }) { + const href = subjectLink(t); + const err = t.lastError + ? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError)) + : null; + return ( +
+ {err ? ( +

+ {err} +

+ ) : null} + + + + + + {subjectText(t)} + + ) : ( + subjectText(t) + ), + }, + { + label: "上级任务", + value: t.parentTaskId ? ( + + {t.parentTaskId.slice(0, 8)} + + ) : null, + }, + { + label: "引擎运行", + value: t.engineRunId ? ( + + {t.engineRunId.slice(0, 12)} + + ) : null, + }, + ]} + /> + +
+ ); +} diff --git a/apps/web/app/components/downloads/cache-volumes.tsx b/apps/web/app/components/downloads/cache-volumes.tsx index b65b2339..1b075882 100644 --- a/apps/web/app/components/downloads/cache-volumes.tsx +++ b/apps/web/app/components/downloads/cache-volumes.tsx @@ -1,10 +1,11 @@ import type { Dashboard } from "@umt/contracts"; -import { Brush, HardDrive } from "lucide-react"; -import { useNavigate } from "react-router"; +import { Brush, HardDrive, SlidersHorizontal } from "lucide-react"; +import { Link, useNavigate } from "react-router"; 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 { consolePath, settingsPath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -76,7 +77,7 @@ function VolumeBar({ v }: { v: Volume }) { ); } -/** 缓存卷用量 / 阈值条 + 手动清理入口 */ +/** 缓存卷水位 / 阈值条 + 阈值设置与手动清理入口(下载队列页头) */ export function CacheVolumes() { const vols = useCacheVolumes(); const cleanup = useCacheCleanup(); @@ -84,34 +85,42 @@ export function CacheVolumes() { return (
-

缓存与保留

- - - 立即清理 - - } - title="按规则清理下载缓存?" - description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。" - confirmLabel="清理" - onConfirm={() => - cleanup.mutateAsync(undefined).then( - (r) => { - toast.success(`已排队清理任务 ${r.taskId.slice(0, 8)}`, { description: "结果见任务详情" }); - navigate(`/console/engine/${r.runId}`); - }, - (e) => toast.error(errorMessage(e)), - ) - } - /> +

缓存水位

+
+ + + + 立即清理 + + } + title="按规则清理下载缓存?" + description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。" + confirmLabel="清理" + onConfirm={() => + cleanup.mutateAsync(undefined).then( + (r) => { + toast.success("清理已排队"); + navigate(consolePath("tasks", r.taskId)); + }, + (e) => toast.error(errorMessage(e)), + ) + } + /> +
{vols.isPending ? ( ) : vols.error ? ( void vols.refetch()} /> ) : !vols.data.length ? ( -

还没有缓存卷数据。配置下载器并完成巡检后显示。

+

还没有缓存卷数据。登记下载器并完成巡检后显示。

) : (
{vols.data.map((v) => ( diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index 136a6ba1..66520dd4 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -648,7 +648,7 @@ export function DeleteDialog({ const reason = filesErrorReason(e); setErr( reason === "permanent_disabled" - ? "系统设置未开启永久删除(files.allowPermanentDelete)" + ? "设置里未开启永久删除(files.allowPermanentDelete)" : reason === "approval_required" ? "用 API Key 删除,每次都需要审批" : filesErrorText(e), @@ -701,7 +701,7 @@ export function DeleteDialog({ {c.downloads.map((d) => (

下载任务{" "} - + {d.taskId.slice(0, 8)} {" "} · {d.status} · H&R{" "} @@ -774,7 +774,7 @@ export function DeleteDialog({ 永久删除 - {isAdmin ? "不可恢复。需要在系统设置里开启。" : "仅管理员会话可用。"} + {isAdmin ? "不可恢复。需要在设置里开启。" : "仅管理员会话可用。"} @@ -858,7 +858,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry ))} -

下载器与媒体服务器看到的路径按“系统设置 → 路径映射”换算。

+

下载器与媒体服务器看到的路径按“设置 → 路径映射”换算。

); diff --git a/apps/web/app/components/files/file-detail.tsx b/apps/web/app/components/files/file-detail.tsx index e5a857f3..65589adf 100644 --- a/apps/web/app/components/files/file-detail.tsx +++ b/apps/web/app/components/files/file-detail.tsx @@ -132,7 +132,7 @@ export function FileDetail({ { label: "任务", value: ( - + {downloadLabel(e.download.status).label} ), diff --git a/apps/web/app/components/files/file-list.tsx b/apps/web/app/components/files/file-list.tsx index 6a07b9d5..5aa602e7 100644 --- a/apps/web/app/components/files/file-list.tsx +++ b/apps/web/app/components/files/file-list.tsx @@ -66,7 +66,7 @@ export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: bo {v.label} {!compact && e.download ? ( ev.stopPropagation()} className="truncate text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" > diff --git a/apps/web/app/components/files/organize-wizard.tsx b/apps/web/app/components/files/organize-wizard.tsx index ea09aac9..410e1077 100644 --- a/apps/web/app/components/files/organize-wizard.tsx +++ b/apps/web/app/components/files/organize-wizard.tsx @@ -385,7 +385,7 @@ export function OrganizeWizard({
-

命名:按系统设置的命名模板;已绑定的作品/季沿用原目录。

+

命名:按设置里的命名模板;已绑定的作品/季沿用原目录。

目标已存在:不覆盖,作为冲突进入待处理。

diff --git a/apps/web/app/components/library/subscriptions-policy.tsx b/apps/web/app/components/library/subscriptions-policy.tsx index e4562944..bac83038 100644 --- a/apps/web/app/components/library/subscriptions-policy.tsx +++ b/apps/web/app/components/library/subscriptions-policy.tsx @@ -208,7 +208,6 @@ export function PolicyWorkspace({ return (
-

草案发布后才生效。回滚会用旧版本内容发布一个新版本。

{q.isPending ? (
diff --git a/apps/web/app/components/workbench/download-dialog.tsx b/apps/web/app/components/workbench/download-dialog.tsx index c501499f..e500fce8 100644 --- a/apps/web/app/components/workbench/download-dialog.tsx +++ b/apps/web/app/components/workbench/download-dialog.tsx @@ -56,7 +56,7 @@ export function DownloadDialog({ if (r.submitted && r.plan) { setRejected(null); setDone(r.plan.id); - toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见“下载与任务”`); + toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见“下载队列”`); } else { setRejected(r.rejected); toast.warning("没有生成下载计划"); @@ -121,7 +121,7 @@ export function DownloadDialog({ {done ? (

计划已生成。 - + 查看

diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 60879648..51f260ad 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -22,6 +22,10 @@ export default [ route("sites", "routes/console/sites.tsx"), route("sites/:siteId", "routes/console/sites.$siteId.tsx"), route("sources", "routes/console/sources.tsx"), + route("rules", "routes/console/rules.tsx"), + route("downloads", "routes/console/downloads.tsx"), + route("downloads/plans", "routes/console/downloads.plans.tsx"), + route("downloads/:id", "routes/console/downloads.$id.tsx"), route("tasks", "routes/console/tasks.tsx"), route("tasks/:id", "routes/console/tasks.$id.tsx"), route("agent", "routes/console/agent.tsx"), diff --git a/apps/web/app/routes/console/downloads.$id.tsx b/apps/web/app/routes/console/downloads.$id.tsx new file mode 100644 index 00000000..38522c66 --- /dev/null +++ b/apps/web/app/routes/console/downloads.$id.tsx @@ -0,0 +1,41 @@ +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 { consolePath } from "~/components/console/nav.ts"; +import { PageHeader } from "~/components/console/page-header.tsx"; +import { DownloadDetail } from "~/components/downloads/download-detail.tsx"; +import { Button } from "~/components/ui/button.tsx"; + +export const meta = () => [{ title: "下载任务 · UHD 媒体自动化控制台" }]; + +export default function DownloadTaskPage() { + const { id = "" } = useParams(); + const task = useDownloadTask(id); + const t = task.data; + return ( + <> + + + + 下载队列 + + + } + /> +
+ {task.isPending ? ( + + ) : task.error ? ( + void task.refetch()} /> + ) : ( + + )} +
+ + ); +} diff --git a/apps/web/app/routes/console/downloads.plans.tsx b/apps/web/app/routes/console/downloads.plans.tsx new file mode 100644 index 00000000..09b52d83 --- /dev/null +++ b/apps/web/app/routes/console/downloads.plans.tsx @@ -0,0 +1,146 @@ +import type { AcquisitionPlan, PlanStatus } from "@umt/contracts"; +import { ClipboardList } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router"; +import { PLAN_STATUS_LABEL, 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 { consolePath } from "~/components/console/nav.ts"; +import { PageHeader } from "~/components/console/page-header.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { useQueryState } from "~/components/console/use-query-state.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { relTime } from "~/components/workbench/release-fields.ts"; +import { formatBytes, formatDateTime } from "~/lib/utils.ts"; + +export const meta = () => [{ title: "下载计划 · UHD 媒体自动化控制台" }]; + +const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const; + +const planTone = (s: PlanStatus) => + s === "failed" ? "destructive" : s === "completed" ? "success" : s === "expired" ? "warning" : "neutral"; + +export default function DownloadPlansPage() { + const f = useQueryState(["status"] as const); + const [openPlan, setOpenPlan] = useState(null); + const plans = usePlans((f.values.status || undefined) as PlanStatus | undefined); + // 计划 → 下载任务:同一列表按 acquisitionPlanId 对上 + const downloads = useDownloadTasks({}); + const rows = plans.data?.pages.flatMap((p) => p.items); + const linkedDownload = openPlan + ? downloads.data?.pages.flatMap((p) => p.items).find((t) => t.acquisitionPlanId === openPlan.id) + : undefined; + + const columns: DataColumn[] = [ + { + key: "purpose", + header: "计划", + cell: (p) => ( +
+

+ {PURPOSE[p.purpose]} · 优先级 {p.priority} +

+

{p.id.slice(0, 8)}

+
+ ), + }, + { + key: "status", + header: "状态", + cell: (p) => {PLAN_STATUS_LABEL[p.status]}, + }, + { + key: "reserved", + header: "预留空间", + hideBelow: "md", + align: "right", + cell: (p) => {formatBytes(p.reservedBytes, 1)}, + }, + { + key: "reason", + header: "说明", + hideBelow: "lg", + cell: (p) => {p.statusReason ?? "—"}, + }, + { + key: "at", + header: "创建", + hideBelow: "sm", + cell: (p) => {relTime(p.createdAt)}, + }, + ]; + + return ( + <> + + + f.set("status", v)} + options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))} + /> + + 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: ClipboardList, + title: f.active ? "没有符合筛选的下载计划" : "还没有下载计划", + description: f.active ? undefined : "候选被选中后出现在这里。", + }} + /> + + !o && setOpenPlan(null)} + title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""} + description={openPlan?.statusReason ?? undefined} + footer={ + linkedDownload ? ( + + ) : null + } + > + {openPlan ? ( + <> + {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: "版本目标", value: openPlan.slotId, mono: true }, + { label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true }, + ]} + /> + + + + + + ) : null} + + + ); +} diff --git a/apps/web/app/routes/console/downloads.tsx b/apps/web/app/routes/console/downloads.tsx new file mode 100644 index 00000000..173d1d9e --- /dev/null +++ b/apps/web/app/routes/console/downloads.tsx @@ -0,0 +1,155 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { DownloadStatus, DownloadTask } from "@umt/contracts"; +import { EventType } from "@umt/contracts"; +import { Download, RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router"; +import { DOWNLOAD_STATUS_LABEL, useDownloaderNames, useDownloadTasks } from "~/api/downloads.ts"; +import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; +import { DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { PageHeader } from "~/components/console/page-header.tsx"; +import { useQueryState } from "~/components/console/use-query-state.ts"; +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 { relTime } from "~/components/workbench/release-fields.ts"; +import { useSse } from "~/lib/sse.ts"; +import { formatBytes } from "~/lib/utils.ts"; + +export const meta = () => [{ title: "下载队列 · UHD 媒体自动化控制台" }]; + +export default function DownloadsPage() { + const f = useQueryState(["status", "managed"] as const); + const [openId, setOpenId] = useState(null); + const qc = useQueryClient(); + const downloads = useDownloadTasks({ + status: (f.values.status || undefined) as DownloadStatus | undefined, + managed: (f.values.managed || undefined) as "true" | "false" | 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 open = rows?.find((t) => t.id === openId) ?? null; + + const columns: DataColumn[] = [ + { + key: "name", + header: "下载任务", + cell: (t) => ( +
+

{t.name}

+

+ {names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"} +

+
+ ), + }, + { key: "status", header: "状态", cell: (t) => }, + { key: "progress", header: "进度", hideBelow: "sm", cell: (t) => }, + { + key: "size", + header: "大小", + hideBelow: "lg", + align: "right", + cell: (t) => ( + + {t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)} + + ), + }, + { + key: "at", + header: "更新", + hideBelow: "md", + cell: (t) => ( + {relTime(t.lastSyncedAt ?? t.updatedAt)} + ), + }, + { key: "ops", header: "", align: "right", cell: (t) => }, + ]; + + return ( + <> + void qc.invalidateQueries({ queryKey: ["downloads"] })}> + + 刷新 + + } + /> + + + f.set("status", v)} + options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))} + /> + f.set("managed", v)} + options={[ + { value: "true", label: "纳管任务" }, + { value: "false", label: "未纳管" }, + ]} + /> + + 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: f.active ? "没有符合筛选的下载任务" : "还没有下载任务", + description: f.active ? undefined : "自动收录或在站点工作台下载后出现在这里。", + }} + /> + + !o && setOpenId(null)} + width="lg" + title={open?.name ?? ""} + footer={ + open ? ( + + ) : null + } + > + {open ? : null} + + + ); +} diff --git a/apps/web/app/routes/console/engine.$runId.tsx b/apps/web/app/routes/console/engine.$runId.tsx index bb746445..b9a53f54 100644 --- a/apps/web/app/routes/console/engine.$runId.tsx +++ b/apps/web/app/routes/console/engine.$runId.tsx @@ -17,7 +17,7 @@ import { errorMessage } from "~/lib/api.ts"; import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts"; import { formatDateTime } from "~/lib/utils.ts"; -export const meta = () => [{ title: "运行详情 · 工作流引擎" }]; +export const meta = () => [{ title: "运行详情 · 引擎面板" }]; const KIND_LABEL: Record = { function: "函数", diff --git a/apps/web/app/routes/console/engine.tsx b/apps/web/app/routes/console/engine.tsx index 782c020a..13e13816 100644 --- a/apps/web/app/routes/console/engine.tsx +++ b/apps/web/app/routes/console/engine.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { EngineRun } from "@umt/contracts"; import { Ban, CheckCircle2, Clock, Cpu, LoaderCircle, RotateCw, XCircle } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import { useEngineRuns, useEngineStats } from "~/api/engine.ts"; import { DataTable } from "~/components/console/data-table.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; @@ -15,7 +15,7 @@ import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationS import { qk } from "~/lib/query-keys.ts"; import { useSse } from "~/lib/sse.ts"; -export const meta = () => [{ title: "工作流引擎 · UHD 媒体自动化控制台" }]; +export const meta = () => [{ title: "引擎面板 · UHD 媒体自动化控制台" }]; const STAT_ORDER = [ { status: "pending", label: "待执行", icon: Clock, tone: "neutral" }, @@ -95,8 +95,15 @@ export default function EnginePage() { return ( <> + 排查用,只读。业务进度看 + + 业务任务 + + + } actions={ + <> + + + } /> diff --git a/apps/web/app/routes/console/redirects.ts b/apps/web/app/routes/console/redirects.ts new file mode 100644 index 00000000..98a72be1 --- /dev/null +++ b/apps/web/app/routes/console/redirects.ts @@ -0,0 +1,48 @@ +import { redirect } from "react-router"; + +// 控制台 IA 重构(docs/design/console-ia.md §3.3)后的旧链接 → 新路由。 +// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。 +// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。 + +/** 旧 query → 新地址;不需要跳转时返回 null */ +export function legacyRedirect(pathname: string, search: string): string | null { + const path = pathname.replace(/\/+$/, ""); + const q = new URLSearchParams(search); + const tab = q.get("tab"); + if (!tab) return null; + q.delete("tab"); + const rest = q.toString(); + const withRest = (to: string) => (rest ? `${to}?${rest}` : to); + + if (path === "/console/tasks") { + if (tab === "downloads") return withRest("/console/downloads"); + if (tab === "plans") return withRest("/console/downloads/plans"); + if (tab === "tasks") return withRest("/console/tasks"); + return null; + } + if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules"); + if (path === "/console/sources" && (tab === "snapshots" || tab === "feeds")) { + const siteId = q.get("siteId"); + if (tab === "snapshots" && siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=snapshots`; + return withRest("/console/sources"); + } + return null; +} + +/** 旧链接样例 → 应落到的新地址(测试与冒烟共用) */ +export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ + { from: "/console/tasks?tab=downloads", to: "/console/downloads" }, + { from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" }, + { from: "/console/tasks?tab=plans", to: "/console/downloads/plans" }, + { from: "/console/tasks?tab=tasks", to: "/console/tasks" }, + { from: "/console/subscriptions?tab=policies", to: "/console/rules" }, + { from: "/console/sources?tab=snapshots", to: "/console/sources" }, + { from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" }, +]; + +/** 路由模块的 clientLoader:命中旧链接就跳转 */ +export function legacyRedirectLoader({ request }: { request: Request }) { + const url = new URL(request.url); + const to = legacyRedirect(url.pathname, url.search); + return to ? redirect(to) : null; +} diff --git a/apps/web/app/routes/console/rules.tsx b/apps/web/app/routes/console/rules.tsx new file mode 100644 index 00000000..d94d252c --- /dev/null +++ b/apps/web/app/routes/console/rules.tsx @@ -0,0 +1,38 @@ +import { FilePlus2, Tags } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router"; +import { consolePath } from "~/components/console/nav.ts"; +import { PageHeader } from "~/components/console/page-header.tsx"; +import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx"; +import { Button } from "~/components/ui/button.tsx"; + +// 收录策略(REQ §6.5):草案 → 预览 → 发布;偏好标签在 /console/preferences,排序时叠加生效。 + +export const meta = () => [{ title: "收录策略 · UHD 媒体自动化控制台" }]; + +export default function RulesPage() { + const [creating, setCreating] = useState(false); + return ( + <> + + + + + } + /> + + + ); +} diff --git a/apps/web/app/routes/console/subscriptions.tsx b/apps/web/app/routes/console/subscriptions.tsx index 8d8943d9..ae0510d9 100644 --- a/apps/web/app/routes/console/subscriptions.tsx +++ b/apps/web/app/routes/console/subscriptions.tsx @@ -9,9 +9,9 @@ import { type SubscriptionProgress, type SubscriptionStatus, } from "@umt/contracts"; -import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react"; +import { CalendarClock, Check, ListChecks, Pause, Play, Plus, Search, XCircle } from "lucide-react"; import { useEffect, useId, useMemo, useState } from "react"; -import { Link, useSearchParams } from "react-router"; +import { Link } from "react-router"; import { toast } from "sonner"; import { useEntitySearch } from "~/api/library.ts"; import { @@ -42,7 +42,6 @@ import { SUB_STATUS, } from "~/components/library/labels.ts"; import { Chip, relTime, Tone } from "~/components/library/parts.tsx"; -import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx"; import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx"; import { Button, buttonVariants } from "~/components/ui/button.tsx"; import { @@ -55,62 +54,42 @@ import { } from "~/components/ui/dialog.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -// 订阅与收录规则:订阅(单次/持续、仅本季/接续)+ 各作品的收录目标 Slot + 收录策略版本(REQ §6.1、§6.4、§6.5) +// 订阅:单次/持续、仅本季/接续 + 各作品的版本目标(REQ §6.1、§6.4)。收录策略在 /console/rules。 + +export const meta = () => [{ title: "订阅 · UHD 媒体自动化控制台" }]; + +// 旧的 ?tab=policies 跳到收录策略 +export const clientLoader = legacyRedirectLoader; const SUB_KIND_LABEL: Record = { movie: "电影", series: "剧集", variety: "综艺" }; export default function SubscriptionsPage() { - const [params, setParams] = useSearchParams(); - const tab = params.get("tab") === "policies" ? "policies" : "subscriptions"; const [creatingSub, setCreatingSub] = useState(false); - const [creatingPolicy, setCreatingPolicy] = useState(false); return ( <> + - ) : ( - - ) + } /> - - setParams( - (p) => { - if (v === "policies") p.set("tab", "policies"); - else p.delete("tab"); - return p; - }, - { replace: true }, - ) - } - > - - 订阅 - 收录策略 - - - - - - - - + {creatingSub ? setCreatingSub(false)} /> : null} ); diff --git a/apps/web/app/routes/console/tasks.$id.tsx b/apps/web/app/routes/console/tasks.$id.tsx index d689a4da..8bcc995e 100644 --- a/apps/web/app/routes/console/tasks.$id.tsx +++ b/apps/web/app/routes/console/tasks.$id.tsx @@ -1,37 +1,162 @@ +import { useQuery } from "@tanstack/react-query"; +import { AssetOperation, DownloadTask, Task } from "@umt/contracts"; import { ArrowLeft } from "lucide-react"; -import { Link, useParams } from "react-router"; -import { useDownloadTask } from "~/api/downloads.ts"; +import { Link, Navigate, useParams } from "react-router"; +import { z } from "zod"; +import { apiParsed, isMissingEndpoint } from "~/api/parse.ts"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; -import { DownloadDetail } from "~/components/downloads/download-detail.tsx"; +import type { StatusTone } from "~/components/console/status-badge.tsx"; +import { Timeline } from "~/components/console/timeline.tsx"; +import { TASK_KIND_LABEL, TaskActions, TaskStatusLine, TaskSummary } from "~/components/downloads/business-tasks.tsx"; import { Button } from "~/components/ui/button.tsx"; +import { TTL_SECOND } from "~/lib/api.ts"; -export default function DownloadTaskPage() { +export const meta = () => [{ title: "业务任务 · UHD 媒体自动化控制台" }]; + +const OP_TONE: Record = { + planned: "neutral", + started: "neutral", + succeeded: "success", + failed: "destructive", + skipped: "neutral", + compensated: "warning", +}; +const OP_STATUS: Record = { + planned: "计划", + started: "进行中", + succeeded: "成功", + failed: "失败", + skipped: "跳过", + compensated: "已回滚", +}; + +const OP_PHASE: Record = { + decision: "判定", + prepare: "准备", + transfer: "传输", + verify: "校验", + commit: "提交", + cleanup: "清理", + compensate: "回滚", +}; +const OP_KIND: Record = { + copy: "复制", + move: "移动", + rename: "改名", + mkdir: "建目录", + hardlink: "硬链接", + delete_source: "删除源文件", + delete_target: "删除目标文件", + verify: "校验", + register: "登记资产", + qb_pause: "暂停下载", + qb_resume: "恢复下载", + qb_remove: "移除下载", + qb_set_tags: "改下载标签", + other: "其他", +}; + +function useTask(id: string) { + return useQuery({ + queryKey: ["tasks", "one", id], + retry: false, + queryFn: ({ signal }) => apiParsed(Task, "/api/v1/tasks/:id", { pathParams: { id }, signal }), + refetchInterval: (q) => + q.state.data && ["pending", "running", "waiting"].includes(q.state.data.status) ? 5 * TTL_SECOND : false, + }); +} + +/** 旧链接 /console/tasks/:id 指向下载任务:业务任务查不到时再查下载任务,查到就跳到下载详情 */ +function useLegacyDownload(id: string, enabled: boolean) { + return useQuery({ + queryKey: ["downloads", "legacy-probe", id], + enabled, + retry: false, + queryFn: ({ signal }) => apiParsed(DownloadTask, "/api/v1/download-tasks/:id", { pathParams: { id }, signal }), + }); +} + +function Operations({ taskId }: { taskId: string }) { + const ops = useQuery({ + queryKey: ["tasks", "operations", taskId], + queryFn: ({ signal }) => + apiParsed(z.object({ items: z.array(AssetOperation) }), "/api/v1/tasks/:id/operations", { + pathParams: { id: taskId }, + signal, + }), + }); + if (ops.isPending) return ; + if (ops.error) return void ops.refetch()} />; + if (!ops.data.items.length) return

这个任务没有文件操作。

; + return ( + ({ + id: o.id, + at: o.startedAt ?? o.createdAt, + tone: OP_TONE[o.status], + title: `${OP_PHASE[o.phase]} · ${OP_KIND[o.kind]}:${OP_STATUS[o.status]}`, + meta: o.attempts > 1 ? `${o.attempts} 次` : undefined, + description: o.organizationRecordId ? ( + + 整理记录 {o.organizationRecordId.slice(0, 8)} + + ) : o.downloadTaskId ? ( + + 下载任务 {o.downloadTaskId.slice(0, 8)} + + ) : undefined, + }))} + /> + ); +} + +export default function BusinessTaskPage() { const { id = "" } = useParams(); - const task = useDownloadTask(id); + const task = useTask(id); + const notFound = !!task.error && isMissingEndpoint(task.error); + const legacy = useLegacyDownload(id, notFound); + + if (notFound && legacy.data) return ; + const t = task.data; + return ( <> : undefined} actions={ - + <> + + {t ? : null} + } /> -
- {task.isPending ? ( + {task.isPending || (notFound && legacy.isPending) ? ( +
- ) : task.error ? ( - void task.refetch()} /> - ) : ( - - )} -
+
+ ) : !t ? ( + void task.refetch()} /> + ) : ( +
+
+ +
+
+ + + +
+
+ )} ); } diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx index 3822d5f6..700915c4 100644 --- a/apps/web/app/routes/console/tasks.tsx +++ b/apps/web/app/routes/console/tasks.tsx @@ -1,313 +1,17 @@ -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 { BusinessTasks } from "~/components/downloads/business-tasks.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"; +import { legacyRedirectLoader } from "./redirects.ts"; -const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const; +export const meta = () => [{ title: "业务任务 · UHD 媒体自动化控制台" }]; + +// 旧的 ?tab=downloads / plans 跳到下载队列 / 下载计划 +export const clientLoader = legacyRedirectLoader; export default function TasksPage() { - const [status, setStatus] = useState(""); - const [managed, setManaged] = useState(""); - const [planStatus, setPlanStatus] = useState(""); - const [openId, setOpenId] = useState(null); - const [openPlan, setOpenPlan] = useState(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[] = [ - { - key: "name", - header: "任务", - cell: (t) => ( -
-

{t.name}

-

- {names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"} -

-
- ), - }, - { key: "status", header: "状态", cell: (t) => }, - { key: "progress", header: "进度", hideBelow: "sm", cell: (t) => }, - { - key: "size", - header: "大小", - hideBelow: "lg", - align: "right", - cell: (t) => ( - - {t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)} - - ), - }, - { - key: "at", - header: "更新", - hideBelow: "md", - cell: (t) => ( - {relTime(t.lastSyncedAt ?? t.updatedAt)} - ), - }, - { key: "ops", header: "", align: "right", cell: (t) => }, - ]; - - const planColumns: DataColumn[] = [ - { - key: "purpose", - header: "计划", - cell: (p) => ( -
-

- {PURPOSE[p.purpose]} · 优先级 {p.priority} -

-

{p.id.slice(0, 8)}

-
- ), - }, - { - key: "status", - header: "状态", - cell: (p) => ( - - {PLAN_STATUS_LABEL[p.status]} - - ), - }, - { - key: "reserved", - header: "预留空间", - hideBelow: "md", - align: "right", - cell: (p) => {formatBytes(p.reservedBytes, 1)}, - }, - { - key: "reason", - header: "说明", - hideBelow: "lg", - cell: (p) => {p.statusReason ?? "—"}, - }, - { - key: "at", - header: "创建", - hideBelow: "sm", - cell: (p) => {relTime(p.createdAt)}, - }, - ]; - return ( <> - void qc.invalidateQueries({ queryKey: ["downloads"] })}> - - 刷新 - - } - /> - - - - 下载任务 - 下载计划 - 业务任务 - - - { - setStatus(""); - setManaged(""); - } - : undefined - } - > - ({ value, label }))} - /> - - - 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: "自动收录或在站点工作台下载后出现在这里。", - }} - /> - - - setPlanStatus("") : undefined}> - ({ value, label }))} - /> - - 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: "候选被选中后生成计划,同时预留版本目标与预算。", - }} - /> - - - - - - - !o && setOpenId(null)} - width="lg" - title={open?.name ?? ""} - footer={ - open ? ( - - ) : null - } - > - {open ? : null} - - - !o && setOpenPlan(null)} - title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""} - description={openPlan?.statusReason ?? undefined} - footer={ - linkedDownload ? ( - - ) : null - } - > - {openPlan ? ( - <> - - - - - - - ) : null} - + + ); } diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index 4c9cc98a..389a47ed 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -30,9 +30,12 @@ const STATIC_ROUTES = [ "/console/files", "/console/files/trash", "/console/subscriptions", + "/console/rules", "/console/preferences", "/console/sites", "/console/sources", + "/console/downloads", + "/console/downloads/plans", "/console/tasks", "/console/agent", "/console/agent?mock=1", @@ -47,11 +50,30 @@ const DETAIL_ROUTES = [ { route: "/console/library/:id", list: "/api/v1/library" }, { route: "/console/organize/:id", list: "/api/v1/organization-records" }, { route: "/console/sites/:id", list: "/api/v1/sites" }, - { route: "/console/tasks/:id", list: "/api/v1/download-tasks" }, + { route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" }, + { route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" }, + { route: "/console/downloads/:id", list: "/api/v1/download-tasks" }, + { route: "/console/tasks/:id", list: "/api/v1/tasks" }, { route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" }, { route: "/console/engine/:id", list: "/api/v1/engine/runs" }, ]; +/** + * IA 重构后的旧链接(docs/design/console-ia.md §3.3):打开旧地址,最终落到新地址且页面 200。 + * 与 app/routes/console/redirects.ts 的 LEGACY_REDIRECTS 同步;:id 从列表接口取。 + */ +const REDIRECT_ROUTES = [ + { from: "/console/tasks?tab=downloads", to: "/console/downloads" }, + { from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" }, + { from: "/console/tasks?tab=plans", to: "/console/downloads/plans" }, + { from: "/console/tasks?tab=tasks", to: "/console/tasks" }, + { from: "/console/subscriptions?tab=policies", to: "/console/rules" }, + { from: "/console/sources?tab=snapshots", to: "/console/sources" }, + { from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" }, + // 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情 + { from: "/console/tasks/:id", to: "/console/downloads/:id", list: "/api/v1/download-tasks", expect404: true }, +]; + /** 允许的控制台噪音(React Router 开发模式提示等,不算错误) */ const IGNORED = [/Download the React DevTools/i, /React Router Future Flag Warning/i]; @@ -148,6 +170,37 @@ async function main() { if (SHOTS) await page.screenshot({ path: join(OUT, `${name}.png`), fullPage: true }).catch(() => {}); results.push({ path, ms: Date.now() - t0, problems: [...new Set(problems)] }); } + // ── 旧链接重定向 ── + for (const r of REDIRECT_ROUTES) { + let from = r.from; + let to = r.to; + if (r.list) { + const res = await page.request.get(`${BASE}${r.list}?limit=1`); + const id = res.ok() ? (await res.json().catch(() => null))?.data?.items?.[0]?.id : null; + if (!id) { + skipped.push(`${r.from}(${r.list} 无数据)`); + continue; + } + from = from.replace(":id", encodeURIComponent(id)); + to = to.replace(":id", encodeURIComponent(id)); + } + problems = []; + const t0 = Date.now(); + let status = 0; + try { + const res = await page.goto(`${BASE}${from}`, { waitUntil: "networkidle", timeout: 30_000 }); + status = res?.status() ?? 0; + await page.waitForURL((u) => `${u.pathname}${u.search}` === to, { timeout: 10_000 }); + await page.waitForSelector("main", { timeout: 10_000 }); + } catch (err) { + const u = new URL(page.url()); + problems.push(`没有跳到 ${to}(停在 ${u.pathname}${u.search}):${String(err?.message ?? err).split("\n")[0]}`); + } + if (status !== 200) problems.push(`HTTP ${status}`); + // 旧下载详情先探测业务任务,那次 404 与浏览器随之打印的资源错误是预期内的 + const kept = r.expect404 ? problems.filter((p) => !/404/.test(p)) : problems; + results.push({ path: `${from} → ${to}`, ms: Date.now() - t0, problems: [...new Set(kept)] }); + } await browser.close(); // ── 汇报 ──