diff --git a/CHANGELOG.md b/CHANGELOG.md index f355ee95..a47fd68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。 +## [Unreleased] + +### 调整 +- 从列表打开的记录不再跳到单独的页面,改在右侧抽屉里展开,列表的位置和筛选都不动:整理记录、订阅、站点(含概览、资源、原站视图、抓取、RSS、账号页签)、下载、日志里的任务,以及规则组、媒体库的未识别资源与外部身份、设置里的下载器等实例。 +- 打开的记录写在地址里,刷新、分享链接都能回到这条;浏览器后退就是关掉抽屉。 +- 抽屉里再看详情(站点的资源和 RSS、订阅对比的格子)在同一个抽屉里切换,顶部「返回」回到上一层,不再叠第二个抽屉。 +- 回收站并进文件管理:左边目录树点「回收站」,右侧换成回收站列表。 +- 设置页左栏只列分区,不再展开节;节是页面里的小标题,搜索和带节的链接照样能定位。 +- 旧的详情页地址(整理记录、订阅、站点、下载、任务、回收站)继续可用,会打开对应列表页上的抽屉。 + ## [0.3.40] - 2026-10-08 迁移 `0091`。 diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts index 18d17abb..708d91cf 100644 --- a/apps/server/src/modules/search/actions.ts +++ b/apps/server/src/modules/search/actions.ts @@ -36,7 +36,7 @@ export const SEARCH_ACTIONS: readonly SearchAction[] = [ "decision", ]), page("files", "整理", "文件管理", "/console/files", ["files", "文件"]), - page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]), + page("trash", "整理", "回收站", "/console/files?view=trash", ["trash", "删除"]), page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]), page("tasks", "系统", "任务", "/console/tasks", ["task", "运行", "历史"]), page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]), diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index 8e3904a1..2aaa8a65 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -10,6 +10,7 @@ import { type TaskKind, type TaskStatus, } from "@umt/contracts"; +import { recordPath, taskPath } from "~/components/console/record-sheet.tsx"; import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts"; import { humanizeReason, reasonLine } from "~/lib/reasons.ts"; import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts"; @@ -250,7 +251,7 @@ export function buildTimeline(input: { .filter(Boolean) .join(" · ") || null, tone: RECORD_TONE[r.status], - href: `/console/organize/${r.id}`, + href: recordPath("organize", r.id), ongoing: r.status === "running" || r.status === "queued", }); } @@ -288,7 +289,7 @@ export function buildTimeline(input: { .filter(Boolean) .join(" · ") || null, tone: TASK_STATUS_TONE[t.status], - href: `/console/tasks/${encodeURIComponent(t.id)}`, + href: taskPath(t.id), ongoing: ACTIVE_TASK.includes(t.status), }); } diff --git a/apps/web/app/components/console/detail-sheet.tsx b/apps/web/app/components/console/detail-sheet.tsx index 1d47bb58..541b057b 100644 --- a/apps/web/app/components/console/detail-sheet.tsx +++ b/apps/web/app/components/console/detail-sheet.tsx @@ -1,54 +1,167 @@ -import type { ReactNode } from "react"; +import { ArrowLeft } from "lucide-react"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "~/components/ui/button.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { cn } from "~/lib/utils.ts"; +/** 桌面宽度:md ≈ 32rem,lg ≈ 44rem,xl ≈ 64rem(内容重的记录:订阅对比、站点);手机一律全宽 */ +export type DetailSheetWidth = "md" | "lg" | "xl"; +const WIDTH: Record = { + md: "sm:max-w-lg", + lg: "sm:max-w-2xl", + xl: "sm:max-w-5xl", +}; + +/** + * 抽屉里再开的 DetailSheet 不叠第二层抽屉(console-ia.md §9):在同一个抽屉里换成子视图, + * 顶部是「返回」,关掉子视图回到原来的内容(滚动位置不丢)。 + */ +type Nest = { + host: HTMLElement | null; + push: (close: () => void) => () => void; +}; +const NestContext = createContext(null); + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: ReactNode; + description?: ReactNode; + /** 标题下面一行的操作按钮(记录抽屉的主操作、更多) */ + actions?: ReactNode; + children: ReactNode; + footer?: ReactNode; + width?: DetailSheetWidth; + className?: string; +}; + /** * 右侧详情抽屉(手机全宽)。内容区自带纵向滚动;footer 固定在底部放操作按钮。 * !o && setId(null)} title="详情" description="…"> * * + * 从列表打开记录时配合 useRecordSheet(record-sheet.tsx)把打开的记录写进地址栏。 */ -export function DetailSheet({ +export function DetailSheet(props: Props) { + const nest = useContext(NestContext); + if (nest) return ; + return ; +} + +function RootSheet({ open, onOpenChange, title, description, + actions, children, footer, width = "md", className, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - title: ReactNode; - description?: ReactNode; - children: ReactNode; - footer?: ReactNode; - /** 桌面宽度:md ≈ 32rem,lg ≈ 44rem */ - width?: "md" | "lg"; - className?: string; -}) { +}: Props) { + const [host, setHost] = useState(null); + const stack = useRef<(() => void)[]>([]); + const [depth, setDepth] = useState(0); + const push = useCallback((close: () => void) => { + stack.current.push(close); + setDepth(stack.current.length); + return () => { + stack.current = stack.current.filter((c) => c !== close); + setDepth(stack.current.length); + }; + }, []); + const nest = useMemo(() => ({ host, push }), [host, push]); return ( { + // 有子视图时 Esc 先回到上一层 + const top = stack.current.at(-1); + if (top) { + e.preventDefault(); + top(); + } + }} > - - {title} - {description ? {description} : null} - -
{children}
- {footer ? ( -
- {footer} + +
0 && "invisible")} aria-hidden={depth > 0}> + + {children} +
- ) : null} +
+ ); } +function SheetFrame({ + title, + description, + actions, + footer, + children, + back, +}: Pick & { back?: () => void }) { + return ( + <> + + {back ? ( + + ) : null} + {back ? ( + // 子视图在同一个对话框里,标题不再占用对话框的 Title / Description + <> +

{title}

+ {description ?
{description}
: null} + + ) : ( + <> + {title} + {description ? ( + +
{description}
+
+ ) : null} + + )} + {actions ?
{actions}
: null} +
+
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} + + ); +} + +/** 抽屉里的子视图:渲染进外层抽屉的同一块区域,盖住原内容 */ +function NestedView({ open, onOpenChange, nest, ...frame }: Props & { nest: Nest }) { + const close = useRef(() => onOpenChange(false)); + close.current = () => onOpenChange(false); + const { push, host } = nest; + useEffect(() => { + if (!open) return; + return push(() => close.current()); + }, [open, push]); + if (!open || !host) return null; + return createPortal( +
+ close.current()} /> +
, + host, + ); +} + /** 抽屉/卡片内的小节:大写小标签 + 内容 */ export function DetailSection({ title, diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx index d6cc53d0..7bf0cf62 100644 --- a/apps/web/app/components/console/inbox-bell.tsx +++ b/apps/web/app/components/console/inbox-bell.tsx @@ -5,6 +5,7 @@ import { Link, useNavigate } from "react-router"; import { useAttention } from "~/api/attention.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { consolePath } from "~/components/console/nav.ts"; +import { recordPath } from "~/components/console/record-sheet.tsx"; import { SEVERITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; @@ -24,7 +25,7 @@ export function approvalHref(d: Pick { expect(findNavItem("/console")?.label).toBe("首页"); expect(findNavItem("/console/")?.label).toBe("首页"); expect(findNavItem("/console/library/abc")?.label).toBe("媒体库"); - // 任务详情独立页归日志 + // 任务详情的旧地址归日志 expect(findNavItem("/console/tasks/r1")?.label).toBe("日志"); expect(pageTitle("/console/tasks/r1")).toBe("日志"); expect(findNavItem("/console/logs")?.label).toBe("日志"); @@ -103,17 +104,39 @@ describe("旧链接重定向", () => { 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), to).toBeNull(); + expect(legacyRedirect(u.pathname, u.search), to).toBeNull(); } }); - it("任务 / 审计 / 计划的旧地址一律落到日志页;任务详情独立页不跳", () => { + it("任务 / 审计 / 计划的旧地址一律落到日志页;任务详情落到日志页的任务抽屉", () => { expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBe("/console/logs?type=task"); expect(legacyRedirect("/console/tasks", "?tab=failed")).toBe("/console/logs?type=task&level=error"); - expect(legacyRedirect("/console/tasks/r1", "")).toBeNull(); + expect(legacyRedirect("/console/tasks/r1", "")).toBe("/console/logs?type=task&run=r1"); expect(legacyRedirect("/console/logs", "?type=task")).toBeNull(); }); + it("记录详情的旧地址落到列表页的抽屉(?open=),其余参数跟在后面", () => { + expect(legacyRedirect("/console/organize/r%201", "")).toBe("/console/organize?open=r+1"); + expect(legacyRedirect("/console/sites/abc", "?tab=accounts")).toBe("/console/sites?open=abc&tab=accounts"); + expect(legacyRedirect("/console/sites/abc/", "?open=x")).toBe("/console/sites?open=abc"); + expect(legacyRedirect("/console/downloads/plans", "")).toBe("/console/downloads"); + expect(legacyRedirect("/console/files/trash", "")).toBe("/console/files?view=trash"); + // 列表页本身和作品页不跳 + expect(legacyRedirect("/console/organize", "?open=r1")).toBeNull(); + expect(legacyRedirect("/console/library/abc", "")).toBeNull(); + }); + + it("站内拼的记录地址都是列表页 + ?open=", () => { + expect(recordPath("subscriptions", "s 1")).toBe("/console/subscriptions?open=s+1"); + expect(recordPath("sites", "abc", { tab: "rss", feed: null })).toBe("/console/sites?open=abc&tab=rss"); + expect(taskPath("run-1")).toBe("/console/logs?type=task&run=run-1"); + for (const to of [recordPath("organize", "x"), recordPath("downloads", "x"), taskPath("x")]) { + const u = new URL(to, "http://x"); + expect(matchesRoute(u.pathname), to).toBe(true); + expect(legacyRedirect(u.pathname, u.search), to).toBeNull(); + } + }); + it("不相关的 query 不跳转", () => { expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull(); expect(legacyRedirect("/console/subscriptions", "")).toBeNull(); diff --git a/apps/web/app/components/console/record-sheet.tsx b/apps/web/app/components/console/record-sheet.tsx new file mode 100644 index 00000000..5c4058c0 --- /dev/null +++ b/apps/web/app/components/console/record-sheet.tsx @@ -0,0 +1,151 @@ +import { useCallback, useRef } from "react"; +import { Link, type LinkProps, type Location, useLocation, useNavigate, useSearchParams } from "react-router"; +import { consolePath } from "~/components/console/nav.ts"; + +// 记录抽屉的地址约定(docs/design/console-ia.md §9):从列表打开的记录 = 列表页上的右侧抽屉, +// 打开的记录写在 ?open=(其余参数不动),刷新、分享、前进后退都能还原; +// 在本页打开是 push(后退关抽屉),关闭时如果是本页 push 的就后退一步,否则 replace 去掉参数。 + +/** 有记录抽屉的列表页 */ +export type RecordPage = "organize" | "subscriptions" | "sites" | "downloads"; +export const OPEN_PARAM = "open"; + +type Extra = Record; +type SheetState = { sheetFrom?: string } | null; + +function applyExtra(p: URLSearchParams, extra?: Extra) { + if (!extra) return; + for (const [k, v] of Object.entries(extra)) { + if (v) p.set(k, v); + else p.delete(k); + } +} + +/** 打开某条记录的地址(从别的页面跳过来用):/console/?open=[&…] */ +export function recordPath(page: RecordPage, id: string, extra?: Extra): string { + const q = new URLSearchParams(); + q.set(OPEN_PARAM, id); + applyExtra(q, extra); + return `${consolePath(page)}?${q}`; +} + +/** 任务(引擎运行)在日志页的抽屉里看:/console/logs?type=task&run= */ +export function taskPath(runId: string): string { + return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`; +} + +/** + * 链接 / 跳转目标:在同一个列表页上时保留当前筛选、记下「本页打开」(关闭时后退)、不滚回顶部; + * 抽屉已开着时原地换成另一条(replace)。 + */ +function recordTarget(location: Location, page: RecordPage, id: string, extra?: Extra) { + const base = consolePath(page); + const same = location.pathname === base; + if (!same) return { to: recordPath(page, id, extra), replace: false, state: undefined, preventScrollReset: false }; + const q = new URLSearchParams(location.search); + const already = q.has(OPEN_PARAM); + q.set(OPEN_PARAM, id); + applyExtra(q, extra); + return { + to: `${base}?${q}`, + replace: already, + state: already ? location.state : ({ sheetFrom: base } satisfies SheetState), + preventScrollReset: true, + }; +} + +/** 指向一条记录的链接:落到所属列表页并打开抽屉 */ +export function RecordLink({ + page, + id, + extra, + ...rest +}: Omit & { page: RecordPage; id: string; extra?: Extra }) { + const location = useLocation(); + const t = recordTarget(location, page, id, extra); + return ; +} + +/** 代码里跳到一条记录(菜单项、按钮) */ +export function useOpenRecord() { + const location = useLocation(); + const navigate = useNavigate(); + return useCallback( + (page: RecordPage, id: string, extra?: Extra) => { + const t = recordTarget(location, page, id, extra); + navigate(t.to, { replace: t.replace, state: t.state, preventScrollReset: t.preventScrollReset }); + }, + [location, navigate], + ); +} + +/** + * 列表页上的记录抽屉状态。 + * const sheet = useRecordSheet({ scoped: ["tab"] }); + * sheet.open(r.id)} selectedKey={sheet.id} /> + * !o && sheet.close()} /> + * key:记录参数名(缺省 open);scoped:只属于抽屉的参数(抽屉内页签等),关闭或换记录时一起去掉; + * drop:打开记录时要关掉的其它面板参数(同一时间只开一个抽屉)。 + */ +export function useRecordSheet({ + key = OPEN_PARAM, + scoped = [], + drop = [], +}: { + key?: string; + scoped?: readonly string[]; + drop?: readonly string[]; +} = {}) { + const [params] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); + const id = params.get(key); + // 日志页的任务可以只带 ?run=(scoped 参数),也算抽屉开着 + const isOpen = !!id || scoped.some((k) => params.has(k)); + const pushed = (location.state as SheetState)?.sheetFrom === location.pathname; + + const go = (mutate: (p: URLSearchParams) => void, replace: boolean, state: unknown) => { + const next = new URLSearchParams(location.search); + mutate(next); + const s = next.toString(); + navigate({ search: s ? `?${s}` : "", hash: location.hash }, { replace, state, preventScrollReset: true }); + }; + + /** 打开一条;已开着时原地换成这条 */ + const open = (nextId: string, extra?: Extra) => + go( + (p) => { + for (const k of [...scoped, ...drop]) p.delete(k); + p.set(key, nextId); + applyExtra(p, extra); + }, + isOpen, + isOpen ? location.state : ({ sheetFrom: location.pathname } satisfies SheetState), + ); + + /** 关闭:本页打开的后退一步(前进还能再打开),直接打开的链接去掉参数 */ + const close = () => { + if (pushed) navigate(-1); + else + go( + (p) => { + p.delete(key); + for (const k of scoped) p.delete(k); + }, + true, + null, + ); + }; + + /** 改抽屉里的参数(页签等),不留历史 */ + const patch = (extra: Extra) => go((p) => applyExtra(p, extra), true, location.state); + + return { id, open, close, patch, params }; +} + +/** 抽屉关闭动画期间还显示刚才那条:open 用当前值,内容用这个 */ +export function useStickyId(id: string | null): string | null { + const last = useRef(id); + if (id) last.current = id; + return last.current; +} diff --git a/apps/web/app/components/dashboard/attention-summary.test.ts b/apps/web/app/components/dashboard/attention-summary.test.ts index 0940bb04..a5a06ea4 100644 --- a/apps/web/app/components/dashboard/attention-summary.test.ts +++ b/apps/web/app/components/dashboard/attention-summary.test.ts @@ -79,7 +79,7 @@ describe("铃铛跳转", () => { `/console/downloads?plan=${id(2)}`, ); expect(approvalHref({ subjectType: "download_task", subjectId: id(3), agentRunId: null })).toBe( - `/console/downloads/${id(3)}`, + `/console/downloads?open=${id(3)}`, ); expect(approvalHref({ subjectType: null, subjectId: null, agentRunId: null })).toBe("/console/agent"); expect(HEALTH_HREF).toBe("/console/status#attention"); diff --git a/apps/web/app/components/downloads/board-format.test.ts b/apps/web/app/components/downloads/board-format.test.ts index 5d285146..8d755034 100644 --- a/apps/web/app/components/downloads/board-format.test.ts +++ b/apps/web/app/components/downloads/board-format.test.ts @@ -32,7 +32,7 @@ describe("下载面板展示换算(DL-2)", () => { expect(organizeState({ status: "completed", organize: rec("succeeded") })).toMatchObject({ label: "已整理", tone: "success", - to: `/console/organize/${RID}`, + to: `/console/organize?open=${RID}`, }); expect(organizeState({ status: "completed", organize: rec("running") })?.label).toBe("整理中"); const failed = organizeState({ @@ -48,7 +48,7 @@ describe("下载面板展示换算(DL-2)", () => { tone: "destructive", hint: "没识别出作品。指定作品后重新整理", }); - expect(failed?.to).toBe(`/console/organize/${RID}`); + expect(failed?.to).toBe(`/console/organize?open=${RID}`); expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe( "磁盘已满", ); diff --git a/apps/web/app/components/downloads/board-format.ts b/apps/web/app/components/downloads/board-format.ts index cab0ccfc..a37c071e 100644 --- a/apps/web/app/components/downloads/board-format.ts +++ b/apps/web/app/components/downloads/board-format.ts @@ -1,5 +1,5 @@ import type { DownloadBoardItem, DownloadBoardState, DownloadLive } from "@umt/contracts"; -import { consolePath } from "~/components/console/nav.ts"; +import { recordPath } from "~/components/console/record-sheet.tsx"; import { reasonLine } from "~/lib/reasons.ts"; import { formatBytes } from "~/lib/utils.ts"; @@ -49,7 +49,7 @@ export function organizeState( const rec = t.organize; if (t.managed === false && !rec && t.status !== "importing" && t.status !== "imported") return null; // 有问题的整理在整理记录详情里就地处理(待处理中心已取消) - const to = rec ? consolePath("organize", rec.recordId) : null; + const to = rec ? recordPath("organize", rec.recordId) : null; if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed")) return { label: "已整理", tone: "success", to, hint: null }; if (rec?.status === "failed") return { label: "整理失败", tone: "destructive", to, hint: reasonHint(rec.reason) }; diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index a7d28e2e..eb0717ce 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -1,27 +1,23 @@ -import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { cursorPage, Task, type TaskKind, type TaskStatus } from "@umt/contracts"; -import { ListTodo, RotateCcw, Square } from "lucide-react"; -import { useState } from "react"; +import { RotateCcw, Square } from "lucide-react"; 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 { apiMutate } from "~/api/parse.ts"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { consolePath } from "~/components/console/nav.ts"; +import { recordPath, taskPath } from "~/components/console/record-sheet.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 { errorMessage, TTL_SECOND } from "~/lib/api.ts"; +import { errorMessage } from "~/lib/api.ts"; import { actorText } from "~/lib/labels.ts"; import { formatDateTime } from "~/lib/utils.ts"; -// 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。 -// 引擎运行只在详情里给链接,引擎面板在系统组。 +// 旧业务任务(task 表)的展示零件:引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。 +// 只剩日志页任务抽屉在打开旧任务 id 时用(components/tasks/legacy-task.tsx)。 -const TaskPage = cursorPage(Task); +const _TaskPage = cursorPage(Task); export const TASK_KIND_LABEL: Record = { ingestFeed: "抓取 RSS", @@ -71,15 +67,15 @@ export function subjectLink(t: Pick): string if (!t.subjectId) return null; switch (t.subjectType) { case "download_task": - return consolePath("downloads", t.subjectId); + return recordPath("downloads", t.subjectId); case "acquisition_plan": return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`; case "organization_record": - return consolePath("organize", t.subjectId); + return recordPath("organize", t.subjectId); case "media_entity": return consolePath("library", t.subjectId); case "site": - return consolePath("sites", t.subjectId); + return recordPath("sites", t.subjectId); case "feed_source": // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`; @@ -147,130 +143,6 @@ export function TaskActions({ t, size = "sm" }: { t: Task; size?: "sm" | "xs" }) 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) => ( -
-

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

-

{subjectText(t)}

-
- ), - }, - { - key: "status", - header: "状态", - cell: (t) => ( -
- {TASK_STATUS[t.status].label} - {t.waitReason ? {t.waitReason} : null} -
- ), - }, - { - key: "attempts", - header: "尝试", - hideBelow: "md", - align: "right", - cell: (t) => ( - - {t.attempts}/{t.maxAttempts} - - ), - }, - { - key: "by", - header: "触发", - hideBelow: "lg", - cell: (t) => {actorText(t.triggeredBy)}, - }, - { - key: "at", - header: "创建", - hideBelow: "sm", - cell: (t) => {relTime(t.createdAt)}, - }, - { key: "ops", header: "", align: "right", cell: (t) => }, - ]; - - return ( - <> - - 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 }))} - /> - - t.id} - 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: 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 ( @@ -280,7 +152,7 @@ export function TaskStatusLine({ t }: { t: Task }) { ); } -/** 概况 + 关联对象(抽屉与详情页共用) */ +/** 概况 + 关联对象 */ export function TaskSummary({ t }: { t: Task }) { const href = subjectLink(t); const err = t.lastError @@ -322,7 +194,7 @@ export function TaskSummary({ t }: { t: Task }) { { label: "上级任务", value: t.parentTaskId ? ( - + {t.parentTaskId.slice(0, 8)} ) : null, @@ -330,7 +202,7 @@ export function TaskSummary({ t }: { t: Task }) { { 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 5021d266..a0bb106f 100644 --- a/apps/web/app/components/downloads/cache-volumes.tsx +++ b/apps/web/app/components/downloads/cache-volumes.tsx @@ -6,7 +6,8 @@ import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts"; import { downloaders } from "~/api/instances.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 { settingsPath } from "~/components/console/nav.ts"; +import { taskPath } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { Button } from "~/components/ui/button.tsx"; @@ -125,7 +126,7 @@ export function CacheVolumes() { cleanup.mutateAsync(undefined).then( (r) => { toast.success("清理已排队"); - navigate(consolePath("tasks", r.taskId)); + navigate(taskPath(r.taskId)); }, (e) => toast.error(errorMessage(e)), ) diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index 9915ed38..5a852d49 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -1,7 +1,6 @@ import type { DownloadLive, DownloadTask } from "@umt/contracts"; import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react"; import { useState } from "react"; -import { Link } from "react-router"; import { toast } from "sonner"; import { DOWNLOAD_STATUS_LABEL, @@ -17,6 +16,7 @@ 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 { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; @@ -144,9 +144,9 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down {importFailed ? (

整理失败:{reasonLine(importFailed.reason)}。 - + 去整理记录处理 - +

) : null} {task.noProgressSince ? ( diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index 59525b02..98414c72 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -39,6 +39,7 @@ import { } from "~/api/files.ts"; import { useSettings } from "~/api/settings.ts"; import { FormField } from "~/components/console/form-field.tsx"; +import { RecordLink, recordPath } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { ScrapeRecordView } from "~/components/library/scrape-panel.tsx"; import { EntityPicker } from "~/components/organize-wizard/entity-picker.tsx"; @@ -355,7 +356,7 @@ export function RenameDialog({ ? { action: { label: "整理记录", - onClick: () => window.location.assign(`/console/organize/${r.recordId}`), + onClick: () => window.location.assign(recordPath("organize", r.recordId as string)), }, } : undefined, @@ -577,7 +578,7 @@ export function TransferDialog({ .map((f) => `${baseName(f.path)}:${f.message}`) .join("\n"), action: recordId - ? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) } + ? { label: "整理记录", onClick: () => window.location.assign(recordPath("organize", recordId)) } : undefined, }); onClose(); @@ -802,7 +803,7 @@ export function DeleteDialog({ description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined, action: mode === "trash" - ? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") } + ? { label: "回收站", onClick: () => window.location.assign("/console/files?view=trash") } : undefined, }, ); @@ -865,9 +866,9 @@ export function DeleteDialog({ {c.downloads.map((d) => (

下载任务{" "} - + {d.taskId.slice(0, 8)} - {" "} + {" "} · {d.status} · H&R{" "} {d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}

diff --git a/apps/web/app/components/files/file-detail.tsx b/apps/web/app/components/files/file-detail.tsx index 4edb7ec0..e95409b7 100644 --- a/apps/web/app/components/files/file-detail.tsx +++ b/apps/web/app/components/files/file-detail.tsx @@ -7,6 +7,7 @@ import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/ import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { LoadingState } from "~/components/console/empty-state.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { Timeline } from "~/components/console/timeline.tsx"; import { SLOT_STATUS } from "~/components/library/labels.ts"; import type { WizardEntity } from "~/components/organize-wizard/model.ts"; @@ -55,9 +56,9 @@ function RecordsSection({ entityId }: { entityId: string }) { at: r.finishedAt ?? r.startedAt ?? r.createdAt, tone: RECORD_STATUS_TONE[r.status], title: ( - + {ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]} - + ), description: r.target.path ? {r.target.path} : null, meta: `${r.counts.succeeded}/${r.counts.total}`, @@ -185,9 +186,9 @@ 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 4fd85bdc..08cfefa3 100644 --- a/apps/web/app/components/files/file-list.tsx +++ b/apps/web/app/components/files/file-list.tsx @@ -21,6 +21,7 @@ import { import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link } from "react-router"; import type { FileEntry } from "~/api/files.ts"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { cn, formatBytes } from "~/lib/utils.ts"; @@ -112,14 +113,15 @@ export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: bo return ( {!compact && e.download ? ( - ev.stopPropagation()} className="min-w-0 truncate hover:underline" title={`${v.hint ?? v.label} · 打开下载任务`} > {badge} - + ) : ( badge )} diff --git a/apps/web/app/components/files/identify-panel.tsx b/apps/web/app/components/files/identify-panel.tsx index 1d8b13a1..f27994ca 100644 --- a/apps/web/app/components/files/identify-panel.tsx +++ b/apps/web/app/components/files/identify-panel.tsx @@ -1,10 +1,10 @@ import { LoaderCircle, Sparkles, Wand2 } from "lucide-react"; import { useEffect } from "react"; -import { Link } from "react-router"; import { type FileEntry, type FileRoot, filesErrorText } from "~/api/files.ts"; import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { candidateEntity, LEVEL, reasonsText } from "~/components/organize-wizard/entity-picker.tsx"; import type { WizardEntity } from "~/components/organize-wizard/model.ts"; @@ -91,9 +91,9 @@ export function IdentifyPanel({ ) : null} {it.records.lastRecordId ? ( - + 查看 - + ) : null}

) : null} diff --git a/apps/web/app/components/files/trash-view.tsx b/apps/web/app/components/files/trash-view.tsx new file mode 100644 index 00000000..3d236f97 --- /dev/null +++ b/apps/web/app/components/files/trash-view.tsx @@ -0,0 +1,439 @@ +import { Clock, File as FileIcon, Folder, LoaderCircle, RotateCcw, Trash, Trash2 } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { useSession } from "~/api/auth.ts"; +import { + type FileRef, + filesErrorText, + filesMockEnabled, + type TrashItem, + useFileRoots, + usePurge, + useRestore, + useTrash, +} from "~/api/files.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { recordPath } from "~/components/console/record-sheet.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { DirPicker } from "~/components/files/dir-picker.tsx"; +import { Checkbox } from "~/components/files/file-list.tsx"; +import { baseName, newIdempotencyKey, parentRel } from "~/components/files/model.ts"; +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 { relTime } from "~/components/workbench/release-fields.ts"; +import { actorText } from "~/lib/labels.ts"; +import { formatBytes, formatDateTime } from "~/lib/utils.ts"; + +const DAY = 86_400_000; + +function expiryTone(iso: string): "destructive" | "warning" | "neutral" { + const left = Date.parse(iso) - Date.now(); + if (left <= 0) return "destructive"; + if (left < DAY) return "warning"; + return "neutral"; +} + +// 回收站:文件管理右侧的一个视图(?view=trash,console-ia.md §9),左边目录树里点「回收站」切过来。 + +export function TrashView({ leading }: { leading?: ReactNode }) { + const trash = useTrash(); + const roots = useFileRoots(); + const session = useSession(); + const isAdmin = session.data?.actor?.type === "admin"; + const restore = useRestore(); + const purge = usePurge(); + const [picked, setPicked] = useState>(new Set()); + const [restoreTo, setRestoreTo] = useState(null); + const [purgeAll, setPurgeAll] = useState(false); + const [purgeIds, setPurgeIds] = useState(null); + const [confirmText, setConfirmText] = useState(""); + /** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */ + const [failedIds, setFailedIds] = useState>(new Map()); + const rows = trash.data?.pages.flatMap((p) => p.items); + const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]); + const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? []; + + const toggle = (id: string) => + setPicked((p) => { + const n = new Set(p); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + + const doRestore = (ids: string[], target?: FileRef) => + restore.mutate( + { idempotencyKey: newIdempotencyKey(), ids, target }, + { + onSuccess: (r) => { + setPicked(new Set()); + setRestoreTo(null); + setFailedIds((prev) => { + const next = new Map(prev); + for (const x of r.restored) next.delete(x.id); + for (const f of r.failed) next.set(f.id, { code: f.code, message: f.message }); + return next; + }); + if (r.restored.length) + toast.success( + `已还原 ${r.restored.length} 项`, + r.recordId + ? { + action: { + label: "整理记录", + onClick: () => window.location.assign(recordPath("organize", r.recordId as string)), + }, + } + : undefined, + ); + if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`); + }, + onError: (e) => toast.error(filesErrorText(e, "还原失败")), + }, + ); + + const doPurge = (ids?: string[]) => + purge + .mutateAsync({ idempotencyKey: newIdempotencyKey(), ids }) + .then((r) => { + setPicked(new Set()); + toast.success(`已清除 ${r.purged.length} 项${r.failed.length ? `,${r.failed.length} 项失败` : ""}`); + }) + .catch((e) => { + toast.error(filesErrorText(e, "清除失败")); + throw e; + }); + + const columns: DataColumn[] = [ + { + key: "pick", + header: ( + 0 && picked.size < (rows?.length ?? 0)} + label="全选" + onToggle={() => setPicked(picked.size === rows?.length ? new Set() : new Set(rows?.map((r) => r.id)))} + /> + ), + className: "w-10", + cell: (r) => ( + toggle(r.id)} /> + ), + }, + { + key: "name", + header: "名称", + cell: (r) => { + const fail = failedIds.get(r.id); + return ( +
+ {r.isDir ? ( + + ) : ( + + )} +
+

+ {baseName(r.originalPath)} +

+ {fail ? ( +

+ {fail.code === "exists" || fail.code === "conflict" + ? "原位置已被占用" + : fail.code === "too_large" + ? "跨卷还原超过大小上限" + : fail.message} +

+ ) : null} +
+
+ ); + }, + }, + { + key: "origin", + header: "原位置", + hideBelow: "md", + cell: (r) => { + const root = rootName.get(r.rootId); + const dir = parentRel(r.originalPath); + return root ? ( + + {dir ? `${root.name}/${dir}` : root.name} + + ) : ( + {r.rootId} + ); + }, + }, + { + key: "size", + header: "大小", + hideBelow: "sm", + align: "right", + cell: (r) => {formatBytes(r.size, 1)}, + }, + { + key: "deleted", + header: "删除", + hideBelow: "md", + cell: (r) => ( + + {relTime(r.deletedAt)} + {r.actor ? {actorText(r.actor)} : null} + + ), + }, + { + key: "expires", + header: "到期清除", + cell: (r) => ( + + + {Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)} + + + ), + }, + { + key: "asset", + header: "媒体库记录", + hideBelow: "lg", + cell: (r) => { + const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null; + if (!snap?.title) return —; + const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`; + return ( + + {snap.mediaEntityId ? ( + + {label} + + ) : ( + label + )} + 已移出媒体库 + + ); + }, + }, + { + key: "act", + header: 操作, + align: "right", + cell: (r) => ( +
+ {failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? ( + + ) : ( + <> + + + + )} + {isAdmin ? ( + + ) : null} +
+ ), + }, + ]; + + return ( + <> +
+ {leading} +
+

+ 回收站 + {filesMockEnabled() ? 演示数据 : null} +

+

+ 到期后自动清除,到期前可以还原。还原不会恢复媒体库记录,需要时重新整理。 +

+
+
+
+ + {isAdmin ? ( + <> + + + + 清除已到期 {expired.length || ""} + + } + title="清除已到期的项?" + description={`${expired.length} 项已过保留期,清除后不可恢复。`} + confirmLabel="清除" + destructive + onConfirm={() => doPurge(undefined)} + /> + + + ) : ( + 立即清除需要管理员会话。 + )} +
+ r.id} + loading={trash.isPending} + error={trash.error} + onRetry={() => void trash.refetch()} + hasMore={trash.hasNextPage} + onLoadMore={() => void trash.fetchNextPage()} + loadingMore={trash.isFetchingNextPage} + empty={{ icon: Trash2, title: "回收站是空的", description: "在文件管理里删除的项会出现在这里。" }} + groupBy={ + new Set(rows?.map((r) => r.rootId)).size > 1 + ? { + key: (r) => r.rootId, + label: (k) => rootName.get(k)?.name ?? k, + storageKey: "files-trash-by-root", + } + : undefined + } + /> + + !o && setPurgeIds(null)} + title={`立即清除 ${purgeIds?.length ?? 0} 项?`} + description="清除后不可恢复。" + confirmLabel="清除" + destructive + onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))} + /> + + {/* 清空全部:二次确认,需输入“清空” */} + { + setPurgeAll(o); + if (!o) setConfirmText(""); + }} + title="清空回收站?" + description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`} + confirmLabel="清空" + confirmDisabled={confirmText.trim() !== "清空"} + destructive + onConfirm={() => { + if (confirmText.trim() !== "清空") { + toast.error("输入「清空」以确认"); + return Promise.reject(new Error("unconfirmed")); + } + return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText("")); + }} + > + setConfirmText(e.target.value)} + placeholder="输入 清空" + aria-label="输入 清空 以确认" + /> + + + {restoreTo ? ( + setRestoreTo(null)} + onPick={(t) => doRestore(restoreTo, t)} + pending={restore.isPending} + /> + ) : null} + + ); +} + +function RestoreToDialog({ + ids, + onClose, + onPick, + pending, +}: { + ids: string[]; + onClose: () => void; + onPick: (t: FileRef) => void; + pending: boolean; +}) { + const roots = useFileRoots(); + const [target, setTarget] = useState(null); + return ( + !o && onClose()}> + + + 还原到… + {ids.length} 项 · 选一个可写的媒体库目录。同名项已存在时不覆盖。 + + + + + + + + + ); +} diff --git a/apps/web/app/components/library/identity-views.tsx b/apps/web/app/components/library/identity-views.tsx index bd436946..08c0473f 100644 --- a/apps/web/app/components/library/identity-views.tsx +++ b/apps/web/app/components/library/identity-views.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from "react"; import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; +import { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx"; import { PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx"; import { @@ -44,7 +45,9 @@ export function PoolTab() { status: (status || undefined) as IdentityCandidateStatus | undefined, reason: (reason || undefined) as IdentityCandidateReason | undefined, }); - const [openId, setOpenId] = useState(null); + // 记录抽屉(console-ia.md §9):?open= + const sheet = useRecordSheet(); + const openId = sheet.id; const rows = pool.data?.pages.flatMap((p) => p.items); const open = rows?.find((r) => r.id === openId) ?? null; @@ -134,7 +137,7 @@ export function PoolTab() { loading={pool.isPending} error={pool.error} onRetry={() => pool.refetch()} - onRowClick={(r) => setOpenId(r.id)} + onRowClick={(r) => sheet.open(r.id)} selectedKey={openId} empty={{ icon: SearchX, @@ -145,7 +148,7 @@ export function PoolTab() { onLoadMore={() => pool.fetchNextPage()} loadingMore={pool.isFetchingNextPage} /> - setOpenId(null)} /> + ); } @@ -157,7 +160,9 @@ export function ExternalTab() { provider: provider || undefined, status: (status || undefined) as IdentityStatus | undefined, }); - const [openId, setOpenId] = useState(null); + // 记录抽屉(console-ia.md §9):?open= + const sheet = useRecordSheet(); + const openId = sheet.id; const rows = list.data?.pages.flatMap((p) => p.items); const open = rows?.find((r) => r.id === openId) ?? null; @@ -235,7 +240,7 @@ export function ExternalTab() { loading={list.isPending} error={list.error} onRetry={() => list.refetch()} - onRowClick={(r) => setOpenId(r.id)} + onRowClick={(r) => sheet.open(r.id)} selectedKey={openId} empty={{ icon: Fingerprint, @@ -246,7 +251,7 @@ export function ExternalTab() { onLoadMore={() => list.fetchNextPage()} loadingMore={list.isFetchingNextPage} /> - setOpenId(null)} /> + ); } diff --git a/apps/web/app/components/library/subscriptions-policy.tsx b/apps/web/app/components/library/subscriptions-policy.tsx index 1ada5d7c..e8ed3979 100644 --- a/apps/web/app/components/library/subscriptions-policy.tsx +++ b/apps/web/app/components/library/subscriptions-policy.tsx @@ -24,6 +24,7 @@ import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty import { FormField } from "~/components/console/form-field.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; +import { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; @@ -190,7 +191,9 @@ export function PolicyWorkspace({ }) { const q = usePolicyRevisions(); const [editor, setEditor] = useState<{ base: PolicyRevision | null; editId: string | null } | null>(null); - const [viewId, setViewId] = useState(null); + // 记录抽屉(console-ia.md §9):查看的策略版本写在 ?open= + const sheet = useRecordSheet(); + const viewId = sheet.id; const groups = useMemo(() => { const m = new Map(); @@ -235,7 +238,7 @@ export function PolicyWorkspace({ key={g.key} policyKey={g.key} revs={g.revs} - onView={setViewId} + onView={(id) => sheet.open(id)} onEdit={(r) => setEditor({ base: r, editId: r.id })} onDraftFrom={(r) => setEditor({ base: r, editId: null })} /> @@ -249,19 +252,19 @@ export function PolicyWorkspace({ onClose={closeEditor} onSaved={(r) => { closeEditor(); - setViewId(r.id); + sheet.open(r.id); }} /> ) : null} setViewId(null)} + onClose={sheet.close} onEdit={(r) => { - setViewId(null); + sheet.close(); setEditor({ base: r, editId: r.id }); }} onDraftFrom={(r) => { - setViewId(null); + sheet.close(); setEditor({ base: r, editId: null }); }} /> diff --git a/apps/web/app/components/library/versions.tsx b/apps/web/app/components/library/versions.tsx index b2f707dd..a91a373b 100644 --- a/apps/web/app/components/library/versions.tsx +++ b/apps/web/app/components/library/versions.tsx @@ -7,11 +7,10 @@ import { } from "@umt/contracts"; import { ChevronDown } from "lucide-react"; import { type ReactNode, useState } from "react"; -import { Link } from "react-router"; import { useLibraryVersions } from "~/api/library-versions.ts"; import { type KvItem, KvList } from "~/components/console/kv-list.tsx"; -import { consolePath } from "~/components/console/nav.ts"; import { PaginatedList } from "~/components/console/paginated-list.tsx"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { Panel } from "~/components/library/parts.tsx"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; @@ -113,9 +112,9 @@ function VersionDetail({ v, match }: { v: AssetVersion; match?: UnitMatch | unde { label: "下载任务", value: v.downloadTaskId ? ( - + 查看下载任务 - + ) : null, }, ].filter((it) => it.value !== null && it.value !== undefined && it.value !== ""); diff --git a/apps/web/app/components/logs/log-detail.tsx b/apps/web/app/components/logs/log-detail.tsx index 659f1742..1aa32246 100644 --- a/apps/web/app/components/logs/log-detail.tsx +++ b/apps/web/app/components/logs/log-detail.tsx @@ -5,12 +5,13 @@ import { Link } from "react-router"; import { useEngineRun } from "~/api/engine.ts"; import { type AgentContext, openAgentDrawer } from "~/components/agent/agent-store.ts"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; -import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { EmptyState, 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 { TechInfo } from "~/components/console/tech-info.tsx"; import { runTitle } from "~/components/tasks/labels.ts"; +import { LegacyTaskSheet } from "~/components/tasks/legacy-task.tsx"; import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx"; import { Button } from "~/components/ui/button.tsx"; import { actorTypeLabel, formatDuration } from "~/lib/labels.ts"; @@ -213,7 +214,7 @@ function AuditBody({ e }: { e: LogEntry }) { ); } -/** 任务:按引擎运行 id 取详情(列表里没有这条也能打开,旧链接 ?run= 用) */ +/** 任务:按引擎运行 id 取详情(列表里没有这条也能打开,旧链接 ?run= 与 /console/tasks/:id 用) */ function RunSheet({ runId, entry, @@ -227,6 +228,7 @@ function RunSheet({ }) { const q = useEngineRun(runId); const r = q.data; + if (q.isError && !r) return ; return ( - {entry ? : null} ) : null } > - {q.isPending ? ( - - ) : q.isError ? ( - q.refetch()} /> - ) : r ? ( - - ) : null} + {q.isPending ? : r ? : null} ); } diff --git a/apps/web/app/components/organize-wizard/step-preview.tsx b/apps/web/app/components/organize-wizard/step-preview.tsx index 10ad27cb..37448a00 100644 --- a/apps/web/app/components/organize-wizard/step-preview.tsx +++ b/apps/web/app/components/organize-wizard/step-preview.tsx @@ -1,8 +1,8 @@ import type { PublicOrganizationRecord } from "@umt/contracts"; import { ArrowRight, TriangleAlert } from "lucide-react"; -import { Link } from "react-router"; import type { FileRoot, FilesOrganizeGroup } from "~/api/files.ts"; import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { baseName, parentRel } from "~/components/files/model.ts"; @@ -202,9 +202,9 @@ export function PreviewGroupCard({ : ""}
{result && g.recordId ? ( - + 整理记录 - + ) : null}
diff --git a/apps/web/app/components/organize/organize-queue.tsx b/apps/web/app/components/organize/organize-queue.tsx index 066faa36..86d8dfbb 100644 --- a/apps/web/app/components/organize/organize-queue.tsx +++ b/apps/web/app/components/organize/organize-queue.tsx @@ -8,6 +8,7 @@ import { useScrapeRecords } from "~/api/files.ts"; import { ACTION_LABEL, useRecord } from "~/api/records.ts"; import { useRecordsForDownload } from "~/api/tasks.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { RecordLink, taskPath } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { relTime } from "~/components/library/parts.tsx"; @@ -48,19 +49,18 @@ function ScrapeQueueItem({ run }: { run: EngineRun }) { 已写入 {record.written} · 跳过 {record.skipped} · 失败 {record.failed} ) : null} - + 任务详情 {record?.organizationRecordId ? ( - 整理记录 - + ) : null}
{detail.error || scrape.error ? ( @@ -116,16 +116,18 @@ function QueueItem({ run }: { run: EngineRun }) {
) : null} {run.attempts > 1 ? 第 {run.attempts} 次尝试 : null} - + 任务详情 {record ? ( - + 整理详情 - + ) : null}
{detail.error || recordQuery.error || related.error || download.error ? ( diff --git a/apps/web/app/components/organize/organize-record-sheet.tsx b/apps/web/app/components/organize/organize-record-sheet.tsx new file mode 100644 index 00000000..41c61d70 --- /dev/null +++ b/apps/web/app/components/organize/organize-record-sheet.tsx @@ -0,0 +1,311 @@ +import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts"; +import { ArrowRight, Bot, ChevronRight, Trash2, Wand2 } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router"; +import { ACTION_LABEL, RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.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 { KvList } from "~/components/console/kv-list.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { PaginatedList } from "~/components/console/paginated-list.tsx"; +import { useStickyId } from "~/components/console/record-sheet.tsx"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { TechInfo } from "~/components/console/tech-info.tsx"; +import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; +import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx"; +import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx"; +import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts"; +import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { actorTypeLabel } from "~/lib/labels.ts"; +import { reasonCode, reasonLine } from "~/lib/reasons.ts"; +import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; + +const FILE_STATUS: Record = { + 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; + +/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */ +function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) { + const noTarget = failed && p.path === null; + return ( +
+

{label}

+

+ {p.path ?? (noTarget ? "未生成目标路径" : "未整理")} +

+
+ ); +} + +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 ( +
  • + + {open ? ( +
    +
    + + + +
    + {reason ? ( +

    + {f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)} +

    + ) : null} +

    + {ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)} +

    + +
    + ) : null} +
  • + ); +} + +/** + * 整理记录抽屉(console-ia.md §9):整理记录页上 ?open=<记录 id> 打开,不再有独立页。 + * 头部:状态与主操作;正文:概况 → 文件明细 → 整理后刮削。 + */ +export function OrganizeRecordSheet({ id, onClose }: { id: string | null; onClose: () => void }) { + const shown = useStickyId(id); + const rec = useRecord(shown ?? undefined); + const r = rec.data?.id === shown ? rec.data : undefined; + const [reorganize, setReorganize] = useState(false); + const [removing, setRemoving] = useState(false); + + return ( + <> + !o && onClose()} + width="lg" + title={r?.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : r ? "未关联作品" : "整理记录"} + description={ + r ? ( + + {RECORD_STATUS_LABEL[r.status]} + + {[ + `${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`, + r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, + r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null, + r.counts.failed ? `${r.counts.failed} 个失败` : null, + r.counts.skipped ? `${r.counts.skipped} 个跳过` : null, + ] + .filter(Boolean) + .join(" · ")} + + + ) : undefined + } + actions={ + r ? ( + <> + {canReorganize(r) ? ( + + ) : null} + {r.status === "failed" || r.status === "partial" ? ( + + ) : null} + {r.media ? ( + + ) : null} + {!isRunning(r) ? ( + + ) : null} + + ) : null + } + > + {rec.isPending ? ( + + ) : rec.error || !r ? ( + void rec.refetch()} /> + ) : ( + setReorganize(true)} /> + )} + + {r ? ( + { + if (removed.includes(r.id)) onClose(); + }} + /> + ) : null} + {reorganize && r ? ( + setReorganize(false)} /> + ) : null} + + ); +} + +function RecordBody({ + r, + onReorganize, +}: { + r: NonNullable["data"]>; + onReorganize: () => void; +}) { + const progress = recordFileProgress(r); + return ( + <> +
    + {progress ? ( +

    + 已完成 {progress.completed}/{progress.total} 个文件 + {progress.current ? ` · 当前文件:${progress.current}` : ""} +

    + ) : null} + {r.reason ? ( +

    + {reasonLine(r.reason)} +

    + ) : null} + {r.attention ? ( +
    +

    {attentionLine(r) ?? "这条记录需要处理"}

    + +
    + ) : null} +
    + + +
    + + `${e.provider}:${e.entityType}:${e.externalId}`).join(", "), + }, + { label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null }, + { label: "变更序号", value: r.changeSeq }, + { label: "记录 ID", value: r.id, wide: true }, + { label: "任务 ID", value: r.taskId, wide: true }, + { label: "种子 hash", value: r.download?.hash, wide: true }, + ]} + /> +
    + + {r.files?.length ? ( + + {(f) => } + + ) : ( +

    + {r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"} +

    + )} +
    + + + ); +} diff --git a/apps/web/app/components/organize/organize-scrape-history.tsx b/apps/web/app/components/organize/organize-scrape-history.tsx index 1b4eac4d..b91b02a1 100644 --- a/apps/web/app/components/organize/organize-scrape-history.tsx +++ b/apps/web/app/components/organize/organize-scrape-history.tsx @@ -2,8 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { FilesScrapeRecordsResult, type ScrapeFileResult } from "@umt/contracts"; import { Link } from "react-router"; import { apiParsed } from "~/api/parse.ts"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { PaginatedList } from "~/components/console/paginated-list.tsx"; +import { taskPath } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { EXPECTED_SCRAPE_SKIPS, SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx"; import { reasonLine } from "~/lib/reasons.ts"; @@ -25,8 +27,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) { refetchInterval: 5_000, }); return ( -
    -

    整理后刮削

    + {query.isPending ? ( ) : query.error ? ( @@ -79,10 +80,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) { ) : null} {record.taskId ? ( - + 查看刮削任务 ) : null} @@ -92,6 +90,6 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) { ) : (

    没有刮削记录。

    )} -
    + ); } diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index bc385026..c06daed1 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -18,6 +18,7 @@ import { Link } from "react-router"; import { toast } from "sonner"; import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; @@ -667,10 +668,10 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => {r.sourceReleaseId ? ( ) : null} @@ -402,9 +405,14 @@ function AccountSiteCard({ site, checks, onDialog }: Props) { {circuit ? {circuit} : null} {cf ? ( cf.takeover ? ( - + {cf.text} - + ) : ( {cf.text} ) @@ -531,9 +539,9 @@ export function SiteTable({ header: "站点", cell: (s) => (
    - + {s.name} - +

    {hostOf(s.baseUrl)}

    ), diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index 5b1df31d..7e7570a6 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -1,11 +1,10 @@ import type { SiteListItem } from "@umt/contracts"; -import { Link } from "react-router"; import { useFeeds } from "~/api/feeds.ts"; import { accessSummary, useSiteFailureSamples, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; -import { consolePath } from "~/components/console/nav.ts"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatTile } from "~/components/console/stat-tile.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; @@ -42,9 +41,9 @@ function cloudflareRow(site: SiteListItem) { value: cf.takeover ? ( {text} - + 去原站视图 - + ) : ( text diff --git a/apps/web/app/components/sites/site-sheet.tsx b/apps/web/app/components/sites/site-sheet.tsx new file mode 100644 index 00000000..66618ec2 --- /dev/null +++ b/apps/web/app/components/sites/site-sheet.tsx @@ -0,0 +1,558 @@ +import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts"; +import { + Activity, + Bot, + Camera, + Globe, + LoaderCircle, + Plus, + Radar, + RefreshCw, + SearchX, + TriangleAlert, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useInventory } from "~/api/inventory.ts"; +import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; +import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts"; +import { openAgentDrawer } from "~/components/agent/agent-store.ts"; +import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx"; +import { 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 { useStickyId } from "~/components/console/record-sheet.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { useListPagination } from "~/components/console/use-list-pagination.ts"; +import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx"; +import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts"; +import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx"; +import { SiteMoreMenu } from "~/components/sites/site-card.tsx"; +import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx"; +import { + ACCOUNT_OVERALL, + hasLogin, + healthLine, + hostOf, + kindLabel, + overallStatus, + primaryAccount, + siteCan, +} from "~/components/sites/site-fields.ts"; +import { SiteOverview } from "~/components/sites/site-overview.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Pagination } from "~/components/ui/pagination.tsx"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; +import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; +import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; +import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts"; +import { ReleaseRow } from "~/components/workbench/release-list.tsx"; +import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +const RESOLUTION: { value: ResolutionStatus; label: string }[] = [ + { value: "resolved", label: "已识别" }, + { value: "unresolved", label: "未识别" }, + { value: "ambiguous", label: "匹配到多部作品" }, + { value: "unidentifiable", label: "无法识别" }, +]; +const AGE = [ + { value: "1", label: "24 小时内" }, + { value: "7", label: "7 天内" }, + { value: "30", label: "30 天内" }, +]; + +/** + * 服务端不支持的维度(上传时间)在已加载结果里筛选,界面上写明。H&R(POLISH-1)、做种与大小(SEARCH-4) + * 在快速筛选里,按全部本地资源计数和筛选。 + */ +type LocalFilters = { age: string }; + +function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) { + 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(value: T, ms = 400) { + const [v, setV] = useState(value); + useEffect(() => { + const t = setTimeout(() => setV(value), ms); + return () => clearTimeout(t); + }, [value, ms]); + return v; +} + +const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const; +type Tab = (typeof TABS)[number]; +/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */ +const LEGACY_TAB: Record = { organized: "resources", feeds: "rss" }; + +/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */ +function visibleTabs(s: SiteListItem | undefined): Tab[] { + if (!s) return [...TABS]; + return TABS.filter((t) => { + if (t === "origin") return s.kind !== "rss_feed"; + if (t === "snapshots") return siteCan(s, "list"); + if (t === "rss") return siteCan(s, "rss"); + if (t === "accounts") return hasLogin(s); + return true; + }); +} + +const TAB_LABEL: Record = { + overview: "概览", + resources: "资源", + origin: "原站视图", + snapshots: "抓取", + rss: "RSS", + accounts: "账号", +}; + +/** + * 站点抽屉(console-ia.md §9):站点页上 ?open=<站点 id> 打开的宽抽屉,里面一层页签(?tab=); + * 资源、RSS 的详情在同一个抽屉里换成子视图,不叠第二层。 + */ +export function SiteSheet({ + id, + params, + onPatch, + onClose, +}: { + id: string | null; + params: URLSearchParams; + onPatch: (extra: Record) => void; + onClose: () => void; +}) { + const shown = useStickyId(id); + const sites = useSites(); + const site = sites.data?.items.find((x) => x.id === shown); + return ( + !o && onClose()} + width="xl" + title={site?.name ?? "站点"} + description={site ? : undefined} + actions={site ? : undefined} + > + {shown ? : null} + + ); +} + +function SiteWorkbench({ + siteId, + params, + onPatch, + onClose, +}: { + siteId: string; + params: URLSearchParams; + onPatch: (extra: Record) => void; + onClose: () => void; +}) { + const [creatingFeed, setCreatingFeed] = useState(false); + const [feedHealth, setFeedHealth] = useState(""); + const [dialog, setDialog] = useState(null); + const checks = useSiteChecks(setDialog); + const sites = useSites(); + const snap = useSnapshotSite(); + const site = sites.data?.items.find((x) => x.id === siteId); + const tabs = visibleTabs(site); + const rawTab = params.get("tab") ?? ""; + let mapped = LEGACY_TAB[rawTab] ?? rawTab; + // 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS + if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss"; + const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview"; + /** 旧 RSS 页 / 任务页的 ?feed=:打开该 RSS 的详情 */ + const feedParam = params.get("feed"); + + const [qInput, setQInput] = useState(""); + const q = useDebounced(qInput.trim()); + const [category, setCategory] = useState(""); + const [resolution, setResolution] = useState(""); + const [local, setLocal] = useState({ age: "" }); + // 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求 + const [quick, setQuick] = useQuickFilters("site"); + const qf = formatQuickFilter(quick); + // 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点 + const [liveAt, setLiveAt] = useState(null); + const live = liveAt !== null && liveAt === (qf ?? ""); + const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null); + const [openId, setOpenId] = useState(null); + + const filters: ReleaseFilters = { + q: q || undefined, + category: category || undefined, + resolution: (resolution || undefined) as ResolutionStatus | undefined, + qf, + }; + const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live); + // 实时补查只对当前搜索生效一次 + // biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先 + useEffect(() => setLiveAt(null), [q, category, resolution]); + + const pages = releases.data?.pages ?? []; + const all = useMemo(() => pages.flatMap((p) => p.items), [pages]); + const coverage = pages[0]?.coverage; + const quickTotal = pages[0]?.total; + /** 本地有资源,但快速筛选后一条都没有 */ + const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0; + const now = Date.now(); + const rows = all.filter((r) => localMatch(r, local, now)); + const paged = useListPagination(rows, { + hasMore: releases.hasNextPage, + onLoadMore: () => releases.fetchNextPage(), + loadingMore: releases.isFetchingNextPage, + error: releases.error, + resetKey: JSON.stringify({ siteId, filters, local, live }), + }); + 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(); + 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; + 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 reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined); + + const reset = () => { + setQInput(""); + setCategory(""); + setQuick({}); + setResolution(""); + setLocal({ age: "" }); + }; + const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick)); + + if (sites.error && !sites.data) return void sites.refetch()} />; + if (sites.data && !s) return ; + + return ( + <> + onPatch({ tab: v === "overview" ? null : v, feed: null })}> + + {tabs.map((t) => ( + + {TAB_LABEL[t]} + + ))} + + + {s ? : } + + + + {s && !siteCan(s, "list") ? null : ( + + )} + {s && !siteCan(s, "search") ? null : ( + + )} + + } + > + + + setLocal((l) => ({ ...l, age: v }))} + options={AGE} + /> + + + {/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */} + {/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */} + + + {coverage || refreshFailed ? ( + + ) : null} + +
    + {releases.isPending ? ( + + ) : releases.error && !releases.data ? ( + void releases.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( + <> +
    + + {quickTotal !== undefined && selectedCount(quick) + ? `共 ${quickTotal} 条,已加载 ${all.length} 条` + : `已加载 ${all.length} 条`} + {localActive ? `,本地筛选后 ${rows.length} 条` : ""} + + {freeCount ? 其中免费 {freeCount} : null} + {reqStats.data ? ( + + 近 24 小时请求站点 {reqStats.data.last24h} 次 + + ) : null} + {releases.isFetching && !releases.isFetchingNextPage ? 刷新中… : null} +
    +
      + {paged.items?.map((r) => ( + setOpenId(r.id)} + /> + ))} +
    + + )} + {all.length > 0 || releases.hasNextPage ? : null} +
    +
    + + {s ? : } + + {s ? : } + + + {/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */} + setFeedHealth("") : undefined} + actions={ + + } + > + ({ value, label: h.label }))} + /> + + + + + + {s ? : } + +
    + + setDialog(null)} onDeleted={onClose} /> + + setOpenId(null)} + /> + + ); +} + +/** 抽屉标题下的一行状态:域名、类型、账号、健康、停用、熔断、抓取过期 */ +function SiteBadges({ s }: { s: SiteListItem }) { + const login = hasLogin(s); + const checkable = s.capabilities.some((c) => c !== "rss"); + const account = login ? ACCOUNT_OVERALL[overallStatus(s)] : null; + const health = checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null; + const circuit = circuitLabel(s); + return ( + +
    + {hostOf(s.baseUrl)} + + {s.kind !== "pt" ? {kindLabel(s.kind)} : null} + {account ? 账号{account.label} : null} + {health ? 健康 {health.text} : null} + {!s.enabled ? 站点已停用,只显示历史数据 : null} + {circuit ? {circuit} : null} + {s.snapshotEnabled && snapshotStale(s) ? 抓取已过期 : null} +
    + ); +} + +/** 抽屉标题下的操作:检测健康、刷新登录、更多、问智能体 */ +function SiteActions({ s, onDeleted }: { s: SiteListItem; onDeleted: () => void }) { + const [dialog, setDialog] = useState(null); + const checks = useSiteChecks(setDialog); + const login = hasLogin(s); + const checkable = s.capabilities.some((c) => c !== "rss"); + const hasAccount = !!s.accounts?.some((a) => a.enabled && a.authKind !== "passkey"); + return ( + <> + {checkable ? ( + <> + + {login ? ( + + ) : null} + + ) : null} + + + setDialog(null)} onDeleted={onDeleted} /> + + ); +} + +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)})` + : "还没抓取过,列表只来自 RSS 和站点搜索", + }); + 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 ( +
    + {notes.map((n) => ( +

    + {n.tone === "warning" ? : null} + {n.text} +

    + ))} +
    + ); +} diff --git a/apps/web/app/components/subscriptions/actions.tsx b/apps/web/app/components/subscriptions/actions.tsx index 42d5ac44..9bb62f6d 100644 --- a/apps/web/app/components/subscriptions/actions.tsx +++ b/apps/web/app/components/subscriptions/actions.tsx @@ -1,7 +1,6 @@ import type { Subscription } from "@umt/contracts"; import { Eraser, Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react"; import { type ReactNode, useState } from "react"; -import { useNavigate } from "react-router"; import { toast } from "sonner"; import { type SubscriptionAction, @@ -11,7 +10,7 @@ import { useSubscriptionBatch, } from "~/api/subscriptions.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; -import { consolePath } from "~/components/console/nav.ts"; +import { useOpenRecord } from "~/components/console/record-sheet.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -114,7 +113,7 @@ export function SubscriptionMenu({ trigger?: ReactNode; }) { const ops = useSubscriptionOps(); - const navigate = useNavigate(); + const openRecord = useOpenRecord(); const [open, setOpen] = useState(false); const [confirm, setConfirm] = useState(false); const [confirmReset, setConfirmReset] = useState(false); @@ -141,7 +140,7 @@ export function SubscriptionMenu({ e.stopPropagation()}> {showDetail ? ( - } onClick={run(() => navigate(consolePath("subscriptions", sub.id)))}> + } onClick={run(() => openRecord("subscriptions", sub.id))}> 查看对比 ) : null} diff --git a/apps/web/app/components/subscriptions/calendar.tsx b/apps/web/app/components/subscriptions/calendar.tsx index b67136ae..ec7bfcad 100644 --- a/apps/web/app/components/subscriptions/calendar.tsx +++ b/apps/web/app/components/subscriptions/calendar.tsx @@ -1,9 +1,8 @@ import type { CalendarEntryState, SubscriptionCalendarEntry, SubscriptionMediaKind } from "@umt/contracts"; import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"; -import { Link } from "react-router"; import { useSubscriptionCalendar } from "~/api/subscriptions.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; -import { consolePath } from "~/components/console/nav.ts"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { Button } from "~/components/ui/button.tsx"; import { cn } from "~/lib/utils.ts"; import { @@ -37,8 +36,9 @@ const DOT_CLS: Record = { function Entry({ e }: { e: SubscriptionCalendarEntry }) { return ( - 1 ? ` ${e.have}/${e.total}` : ""}`} className={cn( "flex min-w-0 items-center gap-1 rounded-tight border px-1.5 py-0.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.06]", @@ -49,7 +49,7 @@ function Entry({ e }: { e: SubscriptionCalendarEntry }) { {e.title} {e.label} {CALENDAR_STATE_LABEL[e.state]} - + ); } @@ -168,8 +168,9 @@ export function SubscriptionCalendarView({
      {unknown.map((u) => (
    • - {u.title} @@ -178,7 +179,7 @@ export function SubscriptionCalendarView({ {u.seasonNumber !== null ? ` · S${String(u.seasonNumber).padStart(2, "0")}` : ""} {u.episodes.length ? ` · ${u.episodes.length} 集` : ""} - +
    • ))}
    diff --git a/apps/web/app/components/subscriptions/overview-item.tsx b/apps/web/app/components/subscriptions/overview-item.tsx index 61633b9f..ba890f2a 100644 --- a/apps/web/app/components/subscriptions/overview-item.tsx +++ b/apps/web/app/components/subscriptions/overview-item.tsx @@ -1,6 +1,5 @@ import type { SubscriptionOverviewItem } from "@umt/contracts"; -import { Link } from "react-router"; -import { consolePath } from "~/components/console/nav.ts"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx"; import { cn } from "~/lib/utils.ts"; @@ -161,12 +160,13 @@ export function SubscriptionCard({
    - - +
    @@ -223,12 +223,13 @@ export function SubscriptionRow({ ) : null}
    - - +
    diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx index cf369049..ba509a1c 100644 --- a/apps/web/app/components/subscriptions/subscription-form.tsx +++ b/apps/web/app/components/subscriptions/subscription-form.tsx @@ -29,6 +29,7 @@ import { } from "~/api/subscriptions.ts"; import { FormField } from "~/components/console/form-field.tsx"; import { consolePath } from "~/components/console/nav.ts"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx"; import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx"; import { hasStructureChoice } from "~/components/library/episode-structure-model.ts"; @@ -474,14 +475,15 @@ export function SubscriptionFormDialog({ className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-foreground" > 这部作品的同一季已有订阅,不能重复创建。 - 打开已有订阅 - +
    ) : null} diff --git a/apps/web/app/components/subscriptions/subscription-sheet.tsx b/apps/web/app/components/subscriptions/subscription-sheet.tsx new file mode 100644 index 00000000..d975c60b --- /dev/null +++ b/apps/web/app/components/subscriptions/subscription-sheet.tsx @@ -0,0 +1,309 @@ +import { Pause, Pencil, Play, RefreshCw, RotateCcw, Search } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { + useRefreshCoverage, + useSubscription, + useSubscriptionCoverage, + useSubscriptionOverviewItem, +} from "~/api/subscriptions.ts"; +import { DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { KvList } from "~/components/console/kv-list.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { useStickyId } from "~/components/console/record-sheet.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { SubscriptionMappingSummary } from "~/components/episode-mapping/subscription-summary.tsx"; +import { PRIORITY, SEASON_SCOPE, SUB_MODE, SUB_ORIGIN, SUB_STATUS } from "~/components/library/labels.ts"; +import { CoverPlaceholder, Panel, relTime, Tone } from "~/components/library/parts.tsx"; +import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx"; +import { SubscriptionMenu, useSubscriptionOps } from "~/components/subscriptions/actions.tsx"; +import { + CellSheet, + CoverageLegend, + CoverageMatrix, + type SelectedCell, +} from "~/components/subscriptions/coverage-matrix.tsx"; +import { + autoFillText, + foldNames, + GROUP_LABEL, + SUB_KIND_LABEL, + seasonLabel, + seasonSlices, + seasonTitle, +} from "~/components/subscriptions/model.ts"; +import { ProgressBlock, SlotBadges } from "~/components/subscriptions/overview-item.tsx"; +import { SlotPanel } from "~/components/subscriptions/slot-panel.tsx"; +import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { actorText } from "~/lib/labels.ts"; +import { cn, formatDateTime } from "~/lib/utils.ts"; + +// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 收录版本矩阵;收录版本排序与自动补全;订阅信息。 +// 记录抽屉(console-ia.md §9):订阅页上 ?open=<订阅 id> 打开的宽抽屉,不再有独立页。 + +const TOTAL_SOURCE: Record = { + tmdb: "TMDB", + injected: "订阅里填写", + manual: "手动", + covered_floor: "按已整理", + previous: "沿用上次", +}; + +export function SubscriptionSheet({ id, onClose }: { id: string | null; onClose: () => void }) { + const shown = useStickyId(id); + return ( + !o && onClose()} width="xl" title={}> + {shown ? : null} + + ); +} + +function SheetTitleText({ id }: { id: string | null }) { + const subQ = useSubscription(id ?? undefined); + const sub = subQ.data; + return <>{sub?.mediaEntityTitle ?? sub?.titleQuery ?? "订阅"}; +} + +function SubscriptionDetail({ id }: { id: string }) { + const subQ = useSubscription(id); + const sub = subQ.data; + const itemQ = useSubscriptionOverviewItem(sub); + const item = itemQ.data; + const covQ = useSubscriptionCoverage(id); + const cov = covQ.data; + const refresh = useRefreshCoverage(); + const ops = useSubscriptionOps(); + const [season, setSeason] = useState(0); + const [sel, setSel] = useState(null); + const [editing, setEditing] = useState(false); + + const seasonTabs = useMemo(() => { + if (!cov) return []; + const slices = seasonSlices(cov); + const any = cov.rows.find((r) => r.slotId === null); + return cov.seasons.map((s, i) => { + const sl = slices[i]!; + const have = any ? any.cells.slice(sl.start, sl.end).filter((c) => c.state === "have").length : 0; + return { + title: seasonTitle(s, cov.mediaKind), + stat: `${have}/${s.totalKnown ? s.total : "?"}`, + source: s.source ? (TOTAL_SOURCE[s.source] ?? s.source) : null, + locked: s.locked, + }; + }); + }, [cov]); + + if (subQ.isPending) return ; + if (subQ.isError || !sub) return subQ.refetch()} />; + + const title = sub.mediaEntityTitle ?? item?.mediaEntityTitle ?? sub.titleQuery ?? "订阅"; + const tracking = sub.status === "active" || sub.status === "pending"; + const ended = sub.status === "completed" || sub.status === "cancelled"; + const sLabel = item ? seasonLabel(item) : null; + const doRefresh = () => + refresh.mutate(sub.id, { + onSuccess: () => toast.success("对比已刷新"), + onError: (e) => toast.error(errorMessage(e)), + }); + const seasonIdx = Math.min(season, Math.max(0, (cov?.seasons.length ?? 1) - 1)); + + return ( + <> +
    + +
    +
    +

    + {[item?.year, SUB_KIND_LABEL[sub.mediaKind], sLabel, SUB_MODE[sub.mode]].filter(Boolean).join(" · ")} +

    +
    +
    + + {item?.group ? {GROUP_LABEL[item.group]} : null} + {item?.upgrading ? 洗版中 : null} + {item?.awaitingNewSeason ? 等待新季 : null} + {!sub.mediaEntityId ? 不在媒体库 : null} +
    + {!sub.mediaEntityId ? ( +

    + 只按标题「{sub.titleQuery ?? title} + 」订阅,还没关联到媒体库作品,不会自动搜索和下载。先把作品加入媒体库,再从作品订阅。 +

    + ) : null} + {item ? ( +
    + + +
    + ) : null} +
    +
    + {tracking && sub.mediaEntityId ? ( + + ) : null} + {!ended ? ( + + ) : null} + {tracking ? ( + + ) : sub.status === "paused" ? ( + + ) : ( + + )} + setEditing(true)} showDetail={false} /> +
    +
    + +
    + + + 刷新对比 + + } + > + {!sub.mediaEntityId ? ( +

    作品加入媒体库后才能对比,加入后自动关联到这个订阅。

    + ) : covQ.isPending ? ( + + ) : covQ.isError ? ( + covQ.refetch()} /> + ) : !cov || cov.seasons.length === 0 ? ( +

    + 还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。 +

    + ) : ( +
    + {cov.seasons.length > 1 || cov.mediaKind !== "movie" ? ( +
    + {seasonTabs.map((t, i) => ( + + ))} +
    + ) : null} + + +
    + )} +
    + + {sub.mediaEntityId ? : null} + + {sub.mediaEntityId && sub.mediaKind !== "movie" ? ( + + ) : null} + +
    + + s.name), + 4, + ) + : null, + }, + { + label: "规则组", + value: item + ? `${foldNames( + item.ruleGroups.map((g) => g.name), + 4, + "未设置", + )}${item.defaultRuleGroups && item.ruleGroups.length ? "(未单独设置)" : ""}` + : null, + }, + { label: "自动补全", value: item ? autoFillText(item.autoFill) : null }, + { label: "上次更新", value: item ? formatDateTime(item.lastUpdatedAt) : null }, + { + label: "主动搜索", + value: sub.lastSearchedAt + ? `上次 ${relTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${relTime(sub.nextSearchAt)}` : item?.searchEnabled === false ? " · 周期搜索已关" : ""}` + : "还没搜索过", + }, + { label: "状态说明", value: sub.statusReason }, + { label: "创建", value: `${formatDateTime(sub.createdAt)} · ${actorText(sub.createdBy)}`, wide: true }, + ...(sub.mediaEntityId + ? [ + { + label: "作品", + value: ( + + 查看作品 + + ), + }, + ] + : []), + ]} + /> + + {sub.mediaEntityId ? ( + + + + ) : null} +
    +
    + + setSel(null)} + /> + {editing ? setEditing(false)} /> : null} + + ); +} diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index 24479efd..52a2a031 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -1,5 +1,6 @@ import type { EngineBusinessKind, EngineRun, EngineTriggeredBy } from "@umt/contracts"; import { consolePath } from "~/components/console/nav.ts"; +import { recordPath } from "~/components/console/record-sheet.tsx"; import type { StatusTone } from "~/components/console/status-badge.tsx"; // 统一任务流的标签:引擎 run + business(W9-I)。一条 run = 一条任务。 @@ -100,10 +101,10 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s const i = (input ?? {}) as { downloadTaskId?: unknown; acquisitionPlanId?: unknown }; if (!i.downloadTaskId && i.acquisitionPlanId === b.refId) return { to: `${consolePath("downloads")}?plan=${id}`, label: "下载" }; - return { to: `/console/downloads/${id}`, label: "下载任务" }; + return { to: recordPath("downloads", id), label: "下载任务" }; } case "organization": - return { to: `/console/organize/${id}`, label: "整理记录" }; + return { to: recordPath("organize", id), label: "整理记录" }; case "agent": return { to: `/console/agent/${id}`, label: "智能体会话" }; case "schedule": @@ -112,7 +113,7 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s // RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" }; case "site": - return { to: `/console/sites/${id}`, label: "站点" }; + return { to: recordPath("sites", id), label: "站点" }; // 事项是内部机制(2026-10-06 待处理中心取消),不单独链出 case "decision": return null; diff --git a/apps/web/app/components/tasks/legacy-task.tsx b/apps/web/app/components/tasks/legacy-task.tsx new file mode 100644 index 00000000..fc234954 --- /dev/null +++ b/apps/web/app/components/tasks/legacy-task.tsx @@ -0,0 +1,169 @@ +import { useQuery } from "@tanstack/react-query"; +import { AssetOperation, DownloadTask, Task } from "@umt/contracts"; +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router"; +import { z } from "zod"; +import { apiParsed, isMissingEndpoint } from "~/api/parse.ts"; +import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { RecordLink, recordPath } from "~/components/console/record-sheet.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 { TTL_SECOND } from "~/lib/api.ts"; + +// 日志页任务抽屉打开的 id 不是引擎运行时(旧链接 /console/tasks/:id):先当旧业务任务查, +// 有引擎运行就换成那条;没有就在抽屉里显示旧任务的概况与文件操作;再查不到当下载任务 id,换到下载面板的抽屉。 + +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, + }); +} + +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 function LegacyTaskSheet({ + id, + runError, + onOpenRun, + onClose, +}: { + id: string; + /** 引擎运行查询的错误(不是「不存在」时直接显示) */ + runError: unknown; + onOpenRun: (runId: string) => void; + onClose: () => void; +}) { + const navigate = useNavigate(); + const runMissing = isMissingEndpoint(runError); + const task = useTask(id); + const notFound = !!task.error && isMissingEndpoint(task.error); + const legacy = useLegacyDownload(id, runMissing && notFound); + const t = task.data; + + const engineRunId = t?.engineRunId; + const openRun = useRef(onOpenRun); + openRun.current = onOpenRun; + useEffect(() => { + if (engineRunId) openRun.current(engineRunId); + }, [engineRunId]); + const downloadId = legacy.data?.id; + useEffect(() => { + if (downloadId) navigate(recordPath("downloads", downloadId), { replace: true }); + }, [downloadId, navigate]); + + return ( + !o && onClose()} + width="lg" + title={t ? (TASK_KIND_LABEL[t.kind] ?? t.kind) : "任务"} + description={t ? : undefined} + footer={t ? : null} + > + {!runMissing ? ( + + ) : task.isPending || (notFound && legacy.isPending) || engineRunId || downloadId ? ( + + ) : !t ? ( + void task.refetch()} /> + ) : ( + <> + + + + + + )} + + ); +} diff --git a/apps/web/app/routes/console/downloads.$id.tsx b/apps/web/app/routes/console/downloads.$id.tsx index 6d297964..c52d9dec 100644 --- a/apps/web/app/routes/console/downloads.$id.tsx +++ b/apps/web/app/routes/console/downloads.$id.tsx @@ -1,41 +1,8 @@ -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"; +import { legacyRedirectLoader } from "./redirects.ts"; -export const meta = () => [{ title: "下载任务 · UHD 媒体自动化控制台" }]; +// 记录抽屉(console-ia.md §9):下载详情是下载面板上的抽屉,旧地址 /console/downloads/:id → ?open=:id +export const clientLoader = legacyRedirectLoader; -export default function DownloadTaskPage() { - const { id = "" } = useParams(); - const task = useDownloadTask(id); - const t = task.data; - return ( - <> - - - - 下载面板 - - - } - /> -
    - {task.isPending ? ( - - ) : task.error ? ( - void task.refetch()} /> - ) : ( - - )} -
    - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/downloads.tsx b/apps/web/app/routes/console/downloads.tsx index f1141dfe..79c485d7 100644 --- a/apps/web/app/routes/console/downloads.tsx +++ b/apps/web/app/routes/console/downloads.tsx @@ -1,15 +1,16 @@ import { useQueryClient } from "@tanstack/react-query"; -import type { DownloadBoardItem, DownloadBoardRow, DownloadBoardState } from "@umt/contracts"; +import type { DownloadBoardItem, DownloadBoardRow, DownloadBoardState, DownloadTask } from "@umt/contracts"; import { EventType } from "@umt/contracts"; import { RefreshCw } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { Link, useSearchParams } from "react-router"; -import { useBoardPlanRow, useDownloadBoard, usePlan } from "~/api/downloads.ts"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; +import { useBoardPlanRow, useDownloadBoard, useDownloadTask, usePlan } from "~/api/downloads.ts"; +import { isMissingEndpoint } from "~/api/parse.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { DetailSheet } from "~/components/console/detail-sheet.tsx"; -import { LoadingState } from "~/components/console/empty-state.tsx"; -import { consolePath } from "~/components/console/nav.ts"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; +import { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { readBoardState, writeBoardState } from "~/components/downloads/board-format.ts"; import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx"; @@ -30,6 +31,7 @@ export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制 // DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。 // DL-3:没提交到下载器的下载(等批准 / 等磁盘空间…)也是「进行中」的行;?plan=<计划 id> 深链打开对应行。 +// 记录抽屉(console-ia.md §9):行点开是右侧抽屉,?open=;旧的下载详情页 /console/downloads/:id 跳到这里。 const COMPLETED = new Set(["completed", "importing", "imported"]); @@ -40,20 +42,31 @@ export default function DownloadsPage() { setStateRaw(s); writeBoardState(s); }; - const [openId, setOpenId] = useState(null); - const [params, setParams] = useSearchParams(); + // 记录抽屉(console-ia.md §9):?open=<下载任务 id 或计划 id>;旧深链 ?plan=<计划 id> 同样打开 + const sheet = useRecordSheet({ scoped: ["plan"] }); + const openId = sheet.id; + const [params] = useSearchParams(); const deepPlan = params.get("plan"); const qc = useQueryClient(); const board = useDownloadBoard(state); - const deep = useBoardPlanRow(deepPlan); - // 深链:找到行就切到它所在的分段并打开;没有行(已结束的下载)打开结束说明 + const pages = board.data?.pages; + const allRows = pages?.flatMap((p) => p.items); + const rows: DownloadBoardRow[] | undefined = + state === "completed" ? allRows?.filter((r): r is DownloadBoardItem => r.kind === "task") : allRows; + const inRows = openId ? allRows?.find((t) => t.id === openId) : undefined; + // 不在当前分段里的:先当下载任务取,取不到再当计划找 + const fetched = useDownloadTask(openId && !inRows && !board.isPending ? openId : undefined); + const taskMissing = !!fetched.error && isMissingEndpoint(fetched.error); + const planId = deepPlan ?? (taskMissing ? openId : null); + const deep = useBoardPlanRow(inRows ? null : planId); + + // 旧深链 ?plan=:切到行所在的分段 useEffect(() => { const row = deep.data; - if (!row) return; + if (!row || !deepPlan) return; setStateRaw(row.kind === "task" && COMPLETED.has(row.status) ? "completed" : "active"); - setOpenId(row.id); - }, [deep.data]); + }, [deep.data, deepPlan]); // 状态变化、阈值事件到达时立即刷新(速度靠轮询) useSse( @@ -66,24 +79,34 @@ export default function DownloadsPage() { () => void qc.invalidateQueries({ queryKey: ["downloads"] }), ); - const pages = board.data?.pages; - const allRows = pages?.flatMap((p) => p.items); - const rows: DownloadBoardRow[] | undefined = - state === "completed" ? allRows?.filter((r): r is DownloadBoardItem => r.kind === "task") : allRows; const first = pages?.[0]; - const open = rows?.find((t) => t.id === openId) ?? (deep.data?.id === openId ? deep.data : null) ?? null; + const openRow = inRows ?? deep.data ?? null; + const openTask: DownloadTask | null = + openRow?.kind === "task" ? openRow : openId && fetched.data?.id === openId ? fetched.data : null; + const openPlan = openRow?.kind === "plan" ? openRow : null; + const sheetOpen = !!(openId || deepPlan); const columns = useMemo( () => (state === "active" ? activeColumns() : (completedColumns() as unknown as DataColumn[])), [state], ); const empty = BOARD_EMPTY[state]; - const closeDeep = () => { - if (!deepPlan) return; - const next = new URLSearchParams(params); - next.delete("plan"); - setParams(next, { replace: true }); - }; - const endedPlan = deepPlan && deep.isSuccess && deep.data === null ? deepPlan : null; + const endedPlan = planId && deep.isSuccess && deep.data === null ? planId : null; + // 关闭动画期间保留刚才的内容 + const last = useRef<{ title: string; body: ReactNode }>({ title: "", body: null }); + if (sheetOpen) + last.current = { + title: openTask?.name ?? openPlan?.name ?? "下载", + body: openTask ? ( + + ) : openPlan ? ( + + ) : fetched.error && !taskMissing ? ( + void fetched.refetch()} /> + ) : ( + + ), + }; + const shown = last.current; return ( <> @@ -109,7 +132,7 @@ export default function DownloadsPage() { loading={board.isPending} error={board.error} onRetry={() => void board.refetch()} - onRowClick={(t) => setOpenId(t.id)} + onRowClick={(t) => sheet.open(t.id)} selectedKey={openId} hasMore={board.hasNextPage} onLoadMore={() => void board.fetchNextPage()} @@ -118,27 +141,15 @@ export default function DownloadsPage() { /> { - if (o) return; - setOpenId(null); - closeDeep(); - }} + open={sheetOpen && !endedPlan} + onOpenChange={(o) => !o && sheet.close()} width="lg" - title={open?.name ?? ""} - footer={ - open?.kind === "task" ? ( - - ) : null - } + title={shown.title} > - {open?.kind === "task" ? : null} - {open?.kind === "plan" ? : null} + {shown.body} - + ); } diff --git a/apps/web/app/routes/console/files.trash.tsx b/apps/web/app/routes/console/files.trash.tsx index 074b247a..048f1510 100644 --- a/apps/web/app/routes/console/files.trash.tsx +++ b/apps/web/app/routes/console/files.trash.tsx @@ -1,437 +1,8 @@ -import { ArrowLeft, Clock, File as FileIcon, Folder, LoaderCircle, RotateCcw, Trash, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; -import { Link } from "react-router"; -import { toast } from "sonner"; -import { useSession } from "~/api/auth.ts"; -import { - type FileRef, - filesErrorText, - filesMockEnabled, - type TrashItem, - useFileRoots, - usePurge, - useRestore, - useTrash, -} from "~/api/files.ts"; -import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; -import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; -import { PageHeader } from "~/components/console/page-header.tsx"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { DirPicker } from "~/components/files/dir-picker.tsx"; -import { Checkbox } from "~/components/files/file-list.tsx"; -import { baseName, newIdempotencyKey, parentRel } from "~/components/files/model.ts"; -import { Button, buttonVariants } 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 { relTime } from "~/components/workbench/release-fields.ts"; -import { actorText } from "~/lib/labels.ts"; -import { formatBytes, formatDateTime } from "~/lib/utils.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -const DAY = 86_400_000; +// 回收站是文件管理里的一个视图(console-ia.md §9):旧地址 /console/files/trash → /console/files?view=trash +export const clientLoader = legacyRedirectLoader; -function expiryTone(iso: string): "destructive" | "warning" | "neutral" { - const left = Date.parse(iso) - Date.now(); - if (left <= 0) return "destructive"; - if (left < DAY) return "warning"; - return "neutral"; -} - -export default function FilesTrashPage() { - const trash = useTrash(); - const roots = useFileRoots(); - const session = useSession(); - const isAdmin = session.data?.actor?.type === "admin"; - const restore = useRestore(); - const purge = usePurge(); - const [picked, setPicked] = useState>(new Set()); - const [restoreTo, setRestoreTo] = useState(null); - const [purgeAll, setPurgeAll] = useState(false); - const [purgeIds, setPurgeIds] = useState(null); - const [confirmText, setConfirmText] = useState(""); - /** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */ - const [failedIds, setFailedIds] = useState>(new Map()); - const rows = trash.data?.pages.flatMap((p) => p.items); - const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]); - const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? []; - - const toggle = (id: string) => - setPicked((p) => { - const n = new Set(p); - if (n.has(id)) n.delete(id); - else n.add(id); - return n; - }); - - const doRestore = (ids: string[], target?: FileRef) => - restore.mutate( - { idempotencyKey: newIdempotencyKey(), ids, target }, - { - onSuccess: (r) => { - setPicked(new Set()); - setRestoreTo(null); - setFailedIds((prev) => { - const next = new Map(prev); - for (const x of r.restored) next.delete(x.id); - for (const f of r.failed) next.set(f.id, { code: f.code, message: f.message }); - return next; - }); - if (r.restored.length) - toast.success( - `已还原 ${r.restored.length} 项`, - r.recordId - ? { - action: { - label: "整理记录", - onClick: () => window.location.assign(`/console/organize/${r.recordId}`), - }, - } - : undefined, - ); - if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`); - }, - onError: (e) => toast.error(filesErrorText(e, "还原失败")), - }, - ); - - const doPurge = (ids?: string[]) => - purge - .mutateAsync({ idempotencyKey: newIdempotencyKey(), ids }) - .then((r) => { - setPicked(new Set()); - toast.success(`已清除 ${r.purged.length} 项${r.failed.length ? `,${r.failed.length} 项失败` : ""}`); - }) - .catch((e) => { - toast.error(filesErrorText(e, "清除失败")); - throw e; - }); - - const columns: DataColumn[] = [ - { - key: "pick", - header: ( - 0 && picked.size < (rows?.length ?? 0)} - label="全选" - onToggle={() => setPicked(picked.size === rows?.length ? new Set() : new Set(rows?.map((r) => r.id)))} - /> - ), - className: "w-10", - cell: (r) => ( - toggle(r.id)} /> - ), - }, - { - key: "name", - header: "名称", - cell: (r) => { - const fail = failedIds.get(r.id); - return ( -
    - {r.isDir ? ( - - ) : ( - - )} -
    -

    - {baseName(r.originalPath)} -

    - {fail ? ( -

    - {fail.code === "exists" || fail.code === "conflict" - ? "原位置已被占用" - : fail.code === "too_large" - ? "跨卷还原超过大小上限" - : fail.message} -

    - ) : null} -
    -
    - ); - }, - }, - { - key: "origin", - header: "原位置", - hideBelow: "md", - cell: (r) => { - const root = rootName.get(r.rootId); - const dir = parentRel(r.originalPath); - return root ? ( - - {dir ? `${root.name}/${dir}` : root.name} - - ) : ( - {r.rootId} - ); - }, - }, - { - key: "size", - header: "大小", - hideBelow: "sm", - align: "right", - cell: (r) => {formatBytes(r.size, 1)}, - }, - { - key: "deleted", - header: "删除", - hideBelow: "md", - cell: (r) => ( - - {relTime(r.deletedAt)} - {r.actor ? {actorText(r.actor)} : null} - - ), - }, - { - key: "expires", - header: "到期清除", - cell: (r) => ( - - - {Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)} - - - ), - }, - { - key: "asset", - header: "媒体库记录", - hideBelow: "lg", - cell: (r) => { - const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null; - if (!snap?.title) return —; - const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`; - return ( - - {snap.mediaEntityId ? ( - - {label} - - ) : ( - label - )} - 已移出媒体库 - - ); - }, - }, - { - key: "act", - header: 操作, - align: "right", - cell: (r) => ( -
    - {failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? ( - - ) : ( - <> - - - - )} - {isAdmin ? ( - - ) : null} -
    - ), - }, - ]; - - return ( - <> - - {filesMockEnabled() ? 演示数据 : null} - - - 文件管理 - - - } - /> -
    - - {isAdmin ? ( - <> - - - - 清除已到期 {expired.length || ""} - - } - title="清除已到期的项?" - description={`${expired.length} 项已过保留期,清除后不可恢复。`} - confirmLabel="清除" - destructive - onConfirm={() => doPurge(undefined)} - /> - - - ) : ( - 立即清除需要管理员会话。 - )} -
    - r.id} - loading={trash.isPending} - error={trash.error} - onRetry={() => void trash.refetch()} - hasMore={trash.hasNextPage} - onLoadMore={() => void trash.fetchNextPage()} - loadingMore={trash.isFetchingNextPage} - empty={{ icon: Trash2, title: "回收站是空的", description: "在文件管理里删除的项会出现在这里。" }} - groupBy={ - new Set(rows?.map((r) => r.rootId)).size > 1 - ? { - key: (r) => r.rootId, - label: (k) => rootName.get(k)?.name ?? k, - storageKey: "files-trash-by-root", - } - : undefined - } - /> - - !o && setPurgeIds(null)} - title={`立即清除 ${purgeIds?.length ?? 0} 项?`} - description="清除后不可恢复。" - confirmLabel="清除" - destructive - onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))} - /> - - {/* 清空全部:二次确认,需输入“清空” */} - { - setPurgeAll(o); - if (!o) setConfirmText(""); - }} - title="清空回收站?" - description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`} - confirmLabel="清空" - confirmDisabled={confirmText.trim() !== "清空"} - destructive - onConfirm={() => { - if (confirmText.trim() !== "清空") { - toast.error("输入「清空」以确认"); - return Promise.reject(new Error("unconfirmed")); - } - return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText("")); - }} - > - setConfirmText(e.target.value)} - placeholder="输入 清空" - aria-label="输入 清空 以确认" - /> - - - {restoreTo ? ( - setRestoreTo(null)} - onPick={(t) => doRestore(restoreTo, t)} - pending={restore.isPending} - /> - ) : null} - - ); -} - -function RestoreToDialog({ - ids, - onClose, - onPick, - pending, -}: { - ids: string[]; - onClose: () => void; - onPick: (t: FileRef) => void; - pending: boolean; -}) { - const roots = useFileRoots(); - const [target, setTarget] = useState(null); - return ( - !o && onClose()}> - - - 还原到… - {ids.length} 项 · 选一个可写的媒体库目录。同名项已存在时不覆盖。 - - - - - - - - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index cb5c0e26..cd7d49a0 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -35,7 +35,7 @@ import { X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router"; +import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useSession } from "~/api/auth.ts"; import { @@ -91,6 +91,7 @@ import { } from "~/components/files/model.ts"; import { PathBreadcrumb } from "~/components/files/path-breadcrumb.tsx"; import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx"; +import { TrashView } from "~/components/files/trash-view.tsx"; import { useIsMobile } from "~/components/files/use-mobile.ts"; import type { WizardEntity } from "~/components/organize-wizard/model.ts"; import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; @@ -212,12 +213,16 @@ function TreeRail({ onSelect, onExpand, trashCount, + trashOpen, + onTrash, }: { roots: FileRoot[]; current: string; onSelect: (loc: Loc) => void; onExpand: () => void; trashCount: string; + trashOpen: boolean; + onTrash: () => void; }) { return ( ); } @@ -289,6 +299,8 @@ export default function FilesPage() { const hidden = params.get("hidden") === "1"; const selParam = params.get("sel"); const identifyAuto = params.get("idf") === "1"; + // 回收站是右侧的一个视图(?view=trash),不再单独一页 + const trashView = params.get("view") === "trash"; const [qInput, setQInput] = useState(params.get("q") ?? ""); const q = params.get("q") ?? ""; const root = rootList.find((r) => r.id === rootId); @@ -311,10 +323,10 @@ export default function FilesPage() { // 初始位置:上次位置 > 第一个下载目录 > 第一个库根(§2 #2) useEffect(() => { - if (rootId || !rootList.length) return; + if (rootId || trashView || !rootList.length) return; const loc = initialLocation(rootList, readLastLoc()); if (loc) patch({ root: loc.root, path: loc.path || null }, true); - }, [rootId, rootList, patch]); + }, [rootId, trashView, rootList, patch]); // 记住上次位置 useEffect(() => { @@ -386,7 +398,7 @@ export default function FilesPage() { const go = (loc: { root: string; path: string }) => { setTreeSheet(false); - patch({ root: loc.root, path: loc.path || null, sel: null, idf: null, q: null }); + patch({ root: loc.root, path: loc.path || null, sel: null, idf: null, q: null, view: null }); setQInput(""); }; const open = (e: FileEntry) => { @@ -394,6 +406,10 @@ export default function FilesPage() { if (e.type === "dir") go({ root: rootId, path: e.path }); else patch({ sel: e.path, idf: null }); }; + const showTrash = () => { + setTreeSheet(false); + patch({ view: "trash", sel: null, idf: null }); + }; const up = () => { if (path) go({ root: rootId, path: parentRel(path) }); }; @@ -589,7 +605,12 @@ export default function FilesPage() { {roots.isPending ? : null} {roots.error ? void roots.refetch()} /> : null} {rootList.length ? ( - + ) : null} {roots.data && !rootList.length ? ( ) : null} - 回收站 {trashCount && trashCount !== "0" ? {trashCount} : null} - +
    ); @@ -895,7 +921,7 @@ export default function FilesPage() { key: "trash", label: `回收站${trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}`, icon: Trash2, - onSelect: () => navigate("/console/files/trash"), + onSelect: showTrash, }, ]; @@ -937,6 +963,8 @@ export default function FilesPage() { onSelect={go} onExpand={() => setTreeOpenStored(true)} trashCount={trashCount} + trashOpen={trashView} + onTrash={showTrash} /> )}
    @@ -975,157 +1003,177 @@ export default function FilesPage() { ) : null} -
    - {/* 工具栏 */} - {mobile ? ( -
    -
    - - {searchOpen ? ( -
    {searchBox}
    - ) : root ? ( - go({ root: root.id, path: p })} - className="min-w-0 flex-1" - /> - ) : ( - - )} - - -
    - {chips} -
    - ) : ( -
    -
    - - {root ? ( - go({ root: root.id, path: p })} - onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)} - className="min-w-0 flex-1" - /> - ) : ( - - )} - - - + {trashView ? ( +
    + setTreeSheet(true)}> + + + ) : null + } + /> +
    + ) : ( +
    + {/* 工具栏 */} + {mobile ? ( +
    +
    + + {searchOpen ? ( +
    {searchBox}
    + ) : root ? ( + go({ root: root.id, path: p })} + className="min-w-0 flex-1" + /> + ) : ( + + )} - - {root ? : null} -
    -
    - {searchBox} + +
    {chips} -
    {sortControls}
    -
    - )} + ) : ( +
    +
    + + {root ? ( + go({ root: root.id, path: p })} + onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)} + className="min-w-0 flex-1" + /> + ) : ( + + )} + + + + + + {root ? : null} +
    +
    + {searchBox} + {chips} +
    {sortControls}
    +
    +
    + )} - {first?.truncated ? ( -
    - 目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。 -
    - ) : null} + {first?.truncated ? ( +
    + 目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。 +
    + ) : null} -
    {body}
    - {!list.isPending && root ? : null} +
    {body}
    + {!list.isPending && root ? : null} - {/* 状态栏 */} - {root && !offline && root.health !== "offline" && !mobile ? ( -
    - - {first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"} - - {selCount ? ( + {/* 状态栏 */} + {root && !offline && root.health !== "offline" && !mobile ? ( +
    - 已选 {selCount} - {totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} + {first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"} - ) : null} - {/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */} - {root.usage ? 剩余 {formatBytes(root.usage.freeBytes, 1)} : null} - {!root.writable && root.kind === "library" ? 只读 : null} -
    - ) : null} - {!mobile ? actionBar : null} -
    + {selCount ? ( + + 已选 {selCount} + {totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} + + ) : null} + {/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */} + {root.usage ? 剩余 {formatBytes(root.usage.freeBytes, 1)} : null} + {!root.writable && root.kind === "library" ? 只读 : null} + + ) : null} + {!mobile ? actionBar : null} + + )} - {mobile ? actionBar : null} + {mobile && !trashView ? actionBar : null} {/* 手机:登记目录与目录树收进左侧抽屉 */} @@ -1139,7 +1187,7 @@ export default function FilesPage() { setMenu(null)} /> - {detail && root ? ( + {detail && root && !trashView ? ( {downloads.map((t) => ( - + {t.name} {t.status === "stalled" ? 没有进展 : null} @@ -288,7 +289,7 @@ function RecentImports({ d }: { d: Dashboard }) { {rows.map((r) => ( {r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"} diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index 844acffe..30b11fe1 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -44,6 +44,7 @@ import { type DataColumn, DataTable } from "~/components/console/data-table.tsx" import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { PaginatedList } from "~/components/console/paginated-list.tsx"; +import { RecordLink } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts"; @@ -889,13 +890,14 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) { header: "下载", cell: (d) => (
    - {d.downloadTask.name} - +

    {d.plan ? `${PURPOSE_LABEL[d.plan.purpose]} · ` : d.downloadTask.origin === "adopted" ? "接管 · " : ""} {formatDateTime(d.downloadTask.createdAt)} @@ -939,14 +941,15 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {

    {d.lastOrganizationStatus ? : null} {d.organizationRecordIds.map((rid, i) => ( - 记录 {i + 1} - + ))}
    ) : ( @@ -1018,13 +1021,14 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) { {(r) => (
  • - {r.targetPath ?? r.sourcePath ?? r.id} - + {r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "智能体" : "自动"} · {r.fileSucceeded}/ {r.fileTotal} 个文件 · {formatDateTime(r.createdAt)} diff --git a/apps/web/app/routes/console/logs.tsx b/apps/web/app/routes/console/logs.tsx index 9474f393..ecdaf5ba 100644 --- a/apps/web/app/routes/console/logs.tsx +++ b/apps/web/app/routes/console/logs.tsx @@ -8,6 +8,7 @@ import { type LogParams, type LogStreamStatus, upsertEntries, useLogStream, useL import { 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 { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { LOG_FILTER_KEYS, @@ -88,6 +89,8 @@ function LogsView({ const [live, setLive] = useState(true); const [selected, setSelected] = useState>(() => new Set()); const anchor = useRef(null); + // 记录抽屉(console-ia.md §9):日志页的记录参数是 ?log=<日志 id>,任务也可以只给 ?run=<引擎运行 id> + const sheet = useRecordSheet({ key: "log", scoped: ["run"] }); const update = (fn: (p: URLSearchParams) => void) => setParams( @@ -276,12 +279,7 @@ function LogsView({ loading={list.isPending} error={list.error} onRetry={() => void list.refetch()} - onRowClick={(r) => - update((p) => { - p.delete("run"); - p.set("log", r.id); - }) - } + onRowClick={(r) => sheet.open(r.id)} selectedKey={openId} hasMore={!!list.hasNextPage} onLoadMore={() => list.fetchNextPage()} @@ -381,22 +379,7 @@ function LogsView({ }, ]} /> - - update((p) => { - p.delete("run"); - p.set("log", id); - }) - } - onClose={() => - update((p) => { - p.delete("log"); - p.delete("run"); - }) - } - /> + sheet.open(id)} onClose={sheet.close} /> ); } diff --git a/apps/web/app/routes/console/organize.$id.tsx b/apps/web/app/routes/console/organize.$id.tsx index c2fe106f..463b453a 100644 --- a/apps/web/app/routes/console/organize.$id.tsx +++ b/apps/web/app/routes/console/organize.$id.tsx @@ -1,311 +1,8 @@ -import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts"; -import { ArrowLeft, ArrowRight, Bot, ChevronRight, Trash2, Wand2 } from "lucide-react"; -import { useState } from "react"; -import { Link, useNavigate, useParams } from "react-router"; -import { ACTION_LABEL, 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 { PaginatedList } from "~/components/console/paginated-list.tsx"; -import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; -import { TechInfo } from "~/components/console/tech-info.tsx"; -import { CoverPlaceholder } from "~/components/library/parts.tsx"; -import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; -import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx"; -import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx"; -import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts"; -import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { actorTypeLabel } from "~/lib/labels.ts"; -import { reasonCode, reasonLine } from "~/lib/reasons.ts"; -import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -const FILE_STATUS: Record = { - 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; +// 记录抽屉(console-ia.md §9):整理记录详情是整理记录页上的抽屉,旧地址 /console/organize/:id → ?open=:id +export const clientLoader = legacyRedirectLoader; -/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */ -function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) { - const noTarget = failed && p.path === null; - return ( -
    -

    {label}

    -

    - {p.path ?? (noTarget ? "未生成目标路径" : "未整理")} -

    -
    - ); -} - -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 ( -
  • - - {open ? ( -
    -
    - - - -
    - {reason ? ( -

    - {f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)} -

    - ) : null} -

    - {ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)} -

    - -
    - ) : null} -
  • - ); -} - -export default function OrganizeRecordPage() { - const { id = "" } = useParams(); - const rec = useRecord(id); - const r = rec.data; - const [reorganize, setReorganize] = useState(false); - const [removing, setRemoving] = useState(false); - const navigate = useNavigate(); - - return ( - <> - - - {r && canReorganize(r) ? ( - - ) : null} - {r && (r.status === "failed" || r.status === "partial") ? ( - - ) : null} - {r && !isRunning(r) ? ( - - ) : null} - - } - /> - {rec.isPending ? ( -
    - -
    - ) : rec.error || !r ? ( - void rec.refetch()} /> - ) : ( -
    -
    -
    - {r.media ? ( - - ) : null} - {RECORD_STATUS_LABEL[r.status]} - - {[ - `${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`, - r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, - r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null, - r.counts.failed ? `${r.counts.failed} 个失败` : null, - r.counts.skipped ? `${r.counts.skipped} 个跳过` : null, - ] - .filter(Boolean) - .join(" · ")} - -
    - {recordFileProgress(r) ? ( -

    - 已完成 {recordFileProgress(r)?.completed}/{recordFileProgress(r)?.total} 个文件 - {recordFileProgress(r)?.current ? ` · 当前文件:${recordFileProgress(r)?.current}` : ""} -

    - ) : null} - {r.reason ? ( -

    - {reasonLine(r.reason)} -

    - ) : null} - {r.attention ? ( -
    -

    {attentionLine(r) ?? "这条记录需要处理"}

    - setReorganize(true)} /> -
    - ) : null} -
    - - - -
    - - `${e.provider}:${e.entityType}:${e.externalId}`).join(", "), - }, - { label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null }, - { label: "变更序号", value: r.changeSeq }, - { label: "记录 ID", value: r.id, wide: true }, - { label: "任务 ID", value: r.taskId, wide: true }, - { label: "种子 hash", value: r.download?.hash, wide: true }, - ]} - /> - {r.media ? ( - - 查看作品 - - ) : null} -
    -
    -

    - 文件明细({r.files?.length ?? 0}) -

    - {r.files?.length ? ( - - {(f) => } - - ) : ( -

    - {r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"} -

    - )} -
    - -
    - )} - {r ? ( - { - if (removed.includes(r.id)) navigate("/console/organize"); - }} - /> - ) : null} - {reorganize && r ? ( - setReorganize(false)} /> - ) : null} - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index c2c54cbd..f49ef379 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -19,7 +19,7 @@ import { X, } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import { useFileRecords } from "~/api/files.ts"; import { useEntityDetail } from "~/api/inventory.ts"; import { @@ -36,6 +36,7 @@ import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { FilterBar } from "~/components/console/filter-bar.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; +import { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { useQueryState } from "~/components/console/use-query-state.ts"; @@ -43,6 +44,7 @@ import { Checkbox } from "~/components/files/file-list.tsx"; import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx"; import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; import { OrganizeQueueButton } from "~/components/organize/organize-queue.tsx"; +import { OrganizeRecordSheet } from "~/components/organize/organize-record-sheet.tsx"; import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx"; import { buildDims, @@ -399,7 +401,7 @@ export default function OrganizePage() { .map((x) => x.trim()) .filter(Boolean); const byIds = useFileRecords({ ids }, ids.length > 0); - const navigate = useNavigate(); + const sheet = useRecordSheet({ drop: ["queue"] }); const qc = useQueryClient(); // 只在关键词真变了时写 URL:否则这次延时写入会带着旧的查询参数,把刚点的维度条件冲掉 useEffect(() => { @@ -642,7 +644,7 @@ export default function OrganizePage() { cell: (r) => ( navigate(`/console/organize/${r.id}`)} + onOpen={() => sheet.open(r.id)} onReorganize={() => reorganize([r])} onDelete={() => setToDelete([r])} onOnlyEntity={() => @@ -743,8 +745,8 @@ export default function OrganizePage() { loading={pinned ? byIds.isPending : records.isPending} error={pinned ? byIds.error : records.error} onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} - onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))} - selectedKey={null} + onRowClick={(r) => (selecting ? toggle(r.id) : sheet.open(r.id))} + selectedKey={sheet.id} hasMore={!pinned && records.hasNextPage} onLoadMore={() => void records.fetchNextPage()} loadingMore={records.isFetchingNextPage} @@ -790,6 +792,7 @@ export default function OrganizePage() { /> ) : null} {recordActions.dialog} + /:id → /console/<列表>?open=:id; +// 任务详情 /console/tasks/:id → 日志页任务抽屉;回收站 /console/files/trash → 文件管理里的回收站视图。 const IDENTITY_TAB: Record = { pool: "filter=unidentified", @@ -21,6 +23,18 @@ export function legacyRedirect(pathname: string, search: string): string | null const rest = q.toString(); const withRest = (to: string) => (rest ? `${to}${to.includes("?") ? "&" : "?"}${rest}` : to); + // 记录抽屉:旧的详情页地址带的参数(站点的 ?tab= 等)原样跟在 ?open= 后面 + const record = RECORD_DETAIL.exec(path); + if (record && !(record[1] === "downloads" && record[2] === "plans")) + return recordRedirect(record[1]!, decodeURIComponent(record[2]!), search); + const task = /^\/console\/tasks\/([^/]+)$/.exec(path); + if (task) + return logsUrl([ + ["type", "task"], + ["run", decodeURIComponent(task[1]!)], + ]); + if (path === "/console/files/trash") return withRest("/console/files?view=trash"); + // 引擎面板 → 日志页「任务」;运行详情用日志页抽屉(?run=) if (path === "/console/engine") return tasksToLogs(new URLSearchParams(search)); const engineRun = /^\/console\/engine\/([^/]+)$/.exec(path); @@ -40,7 +54,7 @@ export function legacyRedirect(pathname: string, search: string): string | null if (path === "/console/sources") { const siteId = q.get("siteId"); if (q.get("feed")) return null; - if (siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=${tab === "snapshots" ? "snapshots" : "rss"}`; + if (siteId) return recordRedirect("sites", siteId, `?tab=${tab === "snapshots" ? "snapshots" : "rss"}`); if (q.get("new")) return "/console/sites?new=rss_feed"; return "/console/sites"; } @@ -67,6 +81,16 @@ export function legacyRedirect(pathname: string, search: string): string | null const LOGS_PATH = "/console/logs"; +/** 有记录抽屉的列表页的旧详情地址;/console/downloads/plans 另有跳转 */ +const RECORD_DETAIL = /^\/console\/(organize|subscriptions|sites|downloads)\/([^/]+)$/; + +function recordRedirect(page: string, id: string, search: string): string { + const q = new URLSearchParams(search); + q.delete("open"); + const rest = q.toString(); + return `/console/${page}?${new URLSearchParams({ open: id })}${rest ? `&${rest}` : ""}`; +} + function logsUrl(pairs: [string, string | null | undefined][]): string { const q = new URLSearchParams(); for (const [k, v] of pairs) if (v) q.set(k, v); @@ -167,11 +191,11 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ { from: "/console/schedules?job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" }, { from: "/console/subscriptions?tab=policies", to: "/console/rules" }, { from: "/console/sources?tab=snapshots", to: "/console/sites" }, - { from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" }, + { from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites?open=abc&tab=snapshots" }, { from: "/console/sources", to: "/console/sites" }, { from: "/console/sources?health=failing", to: "/console/sites" }, - { from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" }, - { from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" }, + { from: "/console/sources?siteId=abc", to: "/console/sites?open=abc&tab=rss" }, + { from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites?open=abc&tab=rss" }, { from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" }, { from: "/console/engine", to: "/console/logs?type=task" }, { from: "/console/engine?status=failed", to: "/console/logs?type=task&level=error" }, @@ -186,6 +210,14 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ { from: "/console/inbox?kind=approval_required", to: "/console/agent" }, { from: "/console/inbox?kind=downloader_offline", to: "/console/status#attention" }, { from: "/console/inbox?kind=episode_mapping", to: "/console/subscriptions" }, + // 记录抽屉(§9) + { from: "/console/organize/r-1", to: "/console/organize?open=r-1" }, + { from: "/console/subscriptions/s-1", to: "/console/subscriptions?open=s-1" }, + { from: "/console/sites/abc", to: "/console/sites?open=abc" }, + { from: "/console/sites/abc?tab=rss&feed=f-1", to: "/console/sites?open=abc&tab=rss&feed=f-1" }, + { from: "/console/downloads/d-1", to: "/console/downloads?open=d-1" }, + { from: "/console/tasks/run-1", to: "/console/logs?type=task&run=run-1" }, + { from: "/console/files/trash", to: "/console/files?view=trash" }, ]; /** 路由模块的 clientLoader:命中旧链接就跳转 */ diff --git a/apps/web/app/routes/console/rules.tsx b/apps/web/app/routes/console/rules.tsx index 37f887e8..03ae546c 100644 --- a/apps/web/app/routes/console/rules.tsx +++ b/apps/web/app/routes/console/rules.tsx @@ -1,10 +1,11 @@ import type { RuleGroup, RuleGroupKind, RuleGroupSource } from "@umt/contracts"; import { FilePlus2, Plus } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import { useRuleGroups, useRuleRefs } from "~/api/rules.ts"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; +import { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx"; import { RuleGroupEditor } from "~/components/rules/group-editor.tsx"; import { RuleGroupTable, RuleTags } from "~/components/rules/group-list.tsx"; @@ -56,17 +57,25 @@ export default function RulesPage() { }); const all = useRuleGroups({}); const refs = useRuleRefs(); - const [editor, setEditor] = useState<{ open: boolean; id: string | null }>({ open: false, id: null }); + // 记录抽屉(console-ia.md §9):打开的规则组在 ?open=(「自动收新作品」的策略版本也用 ?open=,各认自己的 id);新建只在本页状态里 + const sheet = useRecordSheet(); + const [creating, setCreating] = useState(false); + const lastId = useRef(null); + if (sheet.id) lastId.current = sheet.id; + const editId = sheet.id ?? (creating ? null : lastId.current); const [creatingPolicy, setCreatingPolicy] = useState(false); const items = groups.data?.items; const tags = useMemo(() => (items ?? []).filter(isTagLike), [items]); const rest = useMemo(() => items?.filter((g) => !isTagLike(g)), [items]); - const editing: RuleGroup | null = editor.id - ? ((items ?? []).find((g) => g.id === editor.id) ?? all.data?.items.find((g) => g.id === editor.id) ?? null) + const editing: RuleGroup | null = editId + ? ((items ?? []).find((g) => g.id === editId) ?? all.data?.items.find((g) => g.id === editId) ?? null) : null; - const open = (g: RuleGroup) => setEditor({ open: true, id: g.id }); - const create = () => setEditor({ open: true, id: null }); + const open = (g: RuleGroup) => sheet.open(g.id); + const create = () => { + lastId.current = null; + setCreating(true); + }; return ( <> @@ -188,10 +197,14 @@ export default function RulesPage() { setEditor((e) => ({ ...e, open: o }))} + onOpenChange={(o) => { + if (o) return; + if (creating) setCreating(false); + else sheet.close(); + }} /> ); diff --git a/apps/web/app/routes/console/settings.tsx b/apps/web/app/routes/console/settings.tsx index 88a4acaa..69544d51 100644 --- a/apps/web/app/routes/console/settings.tsx +++ b/apps/web/app/routes/console/settings.tsx @@ -1,5 +1,5 @@ import { settingsSectionForLegacyTab } from "@umt/contracts"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Link, redirect, useLocation, useNavigate } from "react-router"; import { PageHeader } from "~/components/console/page-header.tsx"; import { parseSettingsHash, SECTION_GROUPS, settingsHash } from "~/components/settings/sections.tsx"; @@ -45,9 +45,13 @@ export default function SettingsPage() { // 切分区回到顶部;带节锚点时对齐到该节(有字段时对齐到字段并聚焦)。节内容多是异步加载的(上游列表、能力清单), // 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。 + // 打开 / 关闭记录抽屉(?open=,console-ia.md §9)只改 search,不滚动 + const lastSearch = useRef(location.search); // biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次 useEffect(() => { - if (moved) return; + const searchChanged = lastSearch.current !== location.search; + lastSearch.current = location.search; + if (moved || searchChanged) return; if (!anchor) { window.scrollTo({ top: 0 }); return; @@ -91,7 +95,7 @@ export default function SettingsPage() { <>
    - {/* 窄屏:搜索 + 分区下拉 + 节横排 */} + {/* 窄屏:搜索 + 分区下拉。左栏只有一层(console-ia.md §9):节是页面里的小标题,搜索与 #分区/节 深链仍能定位 */}

    {section.description}

    - {section.anchors.length > 1 ? ( - - ) : null}
    - {/* 宽屏:搜索 + 左侧分组导航 */} + {/* 宽屏:搜索 + 左侧分组导航(只列分区,不展开节) */}
    @@ -164,23 +147,6 @@ export default function SettingsPage() { {s.label} - {active && s.anchors.length > 1 ? ( -
      - {s.anchors.map((a) => ( -
    • - - {a.label} - -
    • - ))} -
    - ) : null} ); })} diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 71676d35..de23d4d0 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -1,561 +1,8 @@ -import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts"; -import { - Activity, - ArrowLeft, - Bot, - Camera, - Globe, - LoaderCircle, - Plus, - Radar, - RefreshCw, - SearchX, - TriangleAlert, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams, useSearchParams } from "react-router"; -import { toast } from "sonner"; -import { useInventory } from "~/api/inventory.ts"; -import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; -import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts"; -import { openAgentDrawer } from "~/components/agent/agent-store.ts"; -import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx"; -import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { StatusBadge } from "~/components/console/status-badge.tsx"; -import { useListPagination } from "~/components/console/use-list-pagination.ts"; -import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx"; -import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts"; -import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx"; -import { SiteMoreMenu } from "~/components/sites/site-card.tsx"; -import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx"; -import { - ACCOUNT_OVERALL, - hasLogin, - healthLine, - hostOf, - kindLabel, - overallStatus, - primaryAccount, - siteCan, -} from "~/components/sites/site-fields.ts"; -import { SiteOverview } from "~/components/sites/site-overview.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { Pagination } from "~/components/ui/pagination.tsx"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; -import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; -import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; -import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts"; -import { ReleaseRow } from "~/components/workbench/release-list.tsx"; -import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx"; -import { errorMessage } from "~/lib/api.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -const RESOLUTION: { value: ResolutionStatus; label: string }[] = [ - { value: "resolved", label: "已识别" }, - { value: "unresolved", label: "未识别" }, - { value: "ambiguous", label: "匹配到多部作品" }, - { value: "unidentifiable", label: "无法识别" }, -]; -const AGE = [ - { value: "1", label: "24 小时内" }, - { value: "7", label: "7 天内" }, - { value: "30", label: "30 天内" }, -]; +// 记录抽屉(console-ia.md §9):站点详情是站点页上的宽抽屉,旧地址 /console/sites/:id(?tab=)→ ?open=:id(&tab=) +export const clientLoader = legacyRedirectLoader; -/** - * 服务端不支持的维度(上传时间)在已加载结果里筛选,界面上写明。H&R(POLISH-1)、做种与大小(SEARCH-4) - * 在快速筛选里,按全部本地资源计数和筛选。 - */ -type LocalFilters = { age: string }; - -function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) { - 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(value: T, ms = 400) { - const [v, setV] = useState(value); - useEffect(() => { - const t = setTimeout(() => setV(value), ms); - return () => clearTimeout(t); - }, [value, ms]); - return v; -} - -const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const; -type Tab = (typeof TABS)[number]; -/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */ -const LEGACY_TAB: Record = { organized: "resources", feeds: "rss" }; - -/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */ -function visibleTabs(s: SiteListItem | undefined): Tab[] { - if (!s) return [...TABS]; - return TABS.filter((t) => { - if (t === "origin") return s.kind !== "rss_feed"; - if (t === "snapshots") return siteCan(s, "list"); - if (t === "rss") return siteCan(s, "rss"); - if (t === "accounts") return hasLogin(s); - return true; - }); -} - -export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; - -const TAB_LABEL: Record = { - overview: "概览", - resources: "资源", - origin: "原站视图", - snapshots: "抓取", - rss: "RSS", - accounts: "账号", -}; - -export default function SiteWorkbenchPage() { - const { siteId = "" } = useParams(); - const [params, setParams] = useSearchParams(); - const navigate = useNavigate(); - const [creatingFeed, setCreatingFeed] = useState(false); - const [feedHealth, setFeedHealth] = useState(""); - const [dialog, setDialog] = useState(null); - const checks = useSiteChecks(setDialog); - const sites = useSites(); - const snap = useSnapshotSite(); - const site = sites.data?.items.find((x) => x.id === siteId); - const tabs = visibleTabs(site); - const rawTab = params.get("tab") ?? ""; - let mapped = LEGACY_TAB[rawTab] ?? rawTab; - // 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS - if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss"; - const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview"; - /** 旧 RSS 页 / 任务页的 ?feed=:打开该 RSS 的详情 */ - const feedParam = params.get("feed"); - - const [qInput, setQInput] = useState(""); - const q = useDebounced(qInput.trim()); - const [category, setCategory] = useState(""); - const [resolution, setResolution] = useState(""); - const [local, setLocal] = useState({ age: "" }); - // 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求 - const [quick, setQuick] = useQuickFilters("site"); - const qf = formatQuickFilter(quick); - // 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点 - const [liveAt, setLiveAt] = useState(null); - const live = liveAt !== null && liveAt === (qf ?? ""); - const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null); - const [openId, setOpenId] = useState(null); - - const filters: ReleaseFilters = { - q: q || undefined, - category: category || undefined, - resolution: (resolution || undefined) as ResolutionStatus | undefined, - qf, - }; - const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live); - // 实时补查只对当前搜索生效一次 - // biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先 - useEffect(() => setLiveAt(null), [q, category, resolution]); - - const pages = releases.data?.pages ?? []; - const all = useMemo(() => pages.flatMap((p) => p.items), [pages]); - const coverage = pages[0]?.coverage; - const quickTotal = pages[0]?.total; - /** 本地有资源,但快速筛选后一条都没有 */ - const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0; - const now = Date.now(); - const rows = all.filter((r) => localMatch(r, local, now)); - const paged = useListPagination(rows, { - hasMore: releases.hasNextPage, - onLoadMore: () => releases.fetchNextPage(), - loadingMore: releases.isFetchingNextPage, - error: releases.error, - resetKey: JSON.stringify({ siteId, filters, local, live }), - }); - 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(); - 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; - 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 reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined); - - const reset = () => { - setQInput(""); - setCategory(""); - setQuick({}); - setResolution(""); - setLocal({ age: "" }); - }; - const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick)); - - if (sites.error && !sites.data) return void sites.refetch()} />; - if (sites.data && !s) - return ( - - - - 返回站点 - - - } - /> - ); - - const login = !!s && hasLogin(s); - /** 有站点适配器(不只 RSS)才有首页健康检测 */ - const checkable = !!s && s.capabilities.some((c) => c !== "rss"); - const account = s && login ? ACCOUNT_OVERALL[overallStatus(s)] : null; - const health = s && checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null; - const circuit = s ? circuitLabel(s) : null; - const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey"); - - return ( - <> - - - {hostOf(s.baseUrl)} - - {s.kind !== "pt" ? {kindLabel(s.kind)} : null} - {account ? 账号{account.label} : null} - {health ? 健康 {health.text} : null} - {!s.enabled ? 站点已停用,只显示历史数据 : null} - {circuit ? {circuit} : null} - {s.snapshotEnabled && snapshotStale(s) ? 抓取已过期 : null} - - ) : ( - "加载中…" - ) - } - actions={ - <> - - {s && checkable ? ( - <> - - {login ? ( - - ) : null} - - ) : null} - {s ? : null} - - - } - /> - - - setParams( - (p) => { - if (v === "overview") p.delete("tab"); - else p.set("tab", v); - p.delete("feed"); - return p; - }, - { replace: true }, - ) - } - > - - {tabs.map((t) => ( - - {TAB_LABEL[t]} - - ))} - - - {s ? : } - - - - {s && !siteCan(s, "list") ? null : ( - - )} - {s && !siteCan(s, "search") ? null : ( - - )} - - } - > - - - setLocal((l) => ({ ...l, age: v }))} - options={AGE} - /> - - - {/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */} - {/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */} - - - {coverage || refreshFailed ? ( - - ) : null} - -
    - {releases.isPending ? ( - - ) : releases.error && !releases.data ? ( - void releases.refetch()} /> - ) : rows.length === 0 ? ( - - ) : ( - <> -
    - - {quickTotal !== undefined && selectedCount(quick) - ? `共 ${quickTotal} 条,已加载 ${all.length} 条` - : `已加载 ${all.length} 条`} - {localActive ? `,本地筛选后 ${rows.length} 条` : ""} - - {freeCount ? 其中免费 {freeCount} : null} - {reqStats.data ? ( - - 近 24 小时请求站点 {reqStats.data.last24h} 次 - - ) : null} - {releases.isFetching && !releases.isFetchingNextPage ? 刷新中… : null} -
    -
      - {paged.items?.map((r) => ( - setOpenId(r.id)} - /> - ))} -
    - - )} - {all.length > 0 || releases.hasNextPage ? : null} -
    -
    - - {s ? : } - - {s ? : } - - - {/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */} - setFeedHealth("") : undefined} - actions={ - - } - > - ({ value, label: h.label }))} - /> - - - - - - {s ? : } - -
    - - setDialog(null)} - onDeleted={() => navigate(consolePath("sites"))} - /> - - 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)})` - : "还没抓取过,列表只来自 RSS 和站点搜索", - }); - 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 ( -
    - {notes.map((n) => ( -

    - {n.tone === "warning" ? : null} - {n.text} -

    - ))} -
    - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/sites.tsx b/apps/web/app/routes/console/sites.tsx index 88c46b36..5a47342e 100644 --- a/apps/web/app/routes/console/sites.tsx +++ b/apps/web/app/routes/console/sites.tsx @@ -6,16 +6,19 @@ import { useSites } from "~/api/sites.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { FilterSelect } from "~/components/console/filter-bar.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; +import { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx"; import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx"; import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx"; import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx"; import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts"; +import { SiteSheet } from "~/components/sites/site-sheet.tsx"; import { Button } from "~/components/ui/button.tsx"; import { SegmentedControl } from "~/components/ui/segmented.tsx"; // 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4); // 按类型分组显示:PT 站 / 公开 BT 站(旧的自定义 RSS 源有的话单列);?new=<类型> 直接打开添加。 +// 站点详情是本页的宽抽屉(?open=<站点 id>),旧地址 /console/sites/:id 跳到这里。 const VIEW_KEY = "umt.sites.view"; type View = "cards" | "list"; @@ -55,6 +58,8 @@ export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; export default function SitesPage() { const sites = useSites(); const [params, setParams] = useSearchParams(); + // 记录抽屉(console-ia.md §9):?open=<站点 id>,抽屉里的页签 ?tab=、RSS ?feed= 随抽屉走 + const sheet = useRecordSheet({ scoped: ["tab", "feed"] }); const newParam = params.get("new"); const [adding, setAdding] = useState(null); const [dialog, setDialog] = useState(null); @@ -221,6 +226,7 @@ export default function SitesPage() { {adding ? setAdding(null)} /> : null} setDialog(null)} /> + ); } diff --git a/apps/web/app/routes/console/sources.tsx b/apps/web/app/routes/console/sources.tsx index 49809d22..7ea6db9d 100644 --- a/apps/web/app/routes/console/sources.tsx +++ b/apps/web/app/routes/console/sources.tsx @@ -2,10 +2,11 @@ import { FeedSource } from "@umt/contracts"; import { redirect } from "react-router"; import { apiParsed } from "~/api/parse.ts"; import { consolePath } from "~/components/console/nav.ts"; +import { recordPath } from "~/components/console/record-sheet.tsx"; import { legacyRedirect } from "./redirects.ts"; // IA-2:独立 RSS 页取消,RSS 属于站点(站点详情的 RSS Tab)。这里只做旧链接跳转,深链不失效: -// ?siteId= → 该站点 RSS Tab;?feed= → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。 +// ?siteId= → 该站点抽屉的 RSS Tab;?feed= → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。 export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }]; @@ -14,7 +15,7 @@ export async function clientLoader({ request }: { request: Request }) { const feed = url.searchParams.get("feed"); if (feed) { const f = await apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: feed } }).catch(() => null); - if (f?.siteId) return redirect(`${consolePath("sites", f.siteId)}?tab=rss&feed=${encodeURIComponent(feed)}`); + if (f?.siteId) return redirect(recordPath("sites", f.siteId, { tab: "rss", feed })); return redirect(consolePath("sites")); } return redirect(legacyRedirect(url.pathname, url.search) ?? consolePath("sites")); diff --git a/apps/web/app/routes/console/subscriptions.$id.tsx b/apps/web/app/routes/console/subscriptions.$id.tsx index b5afb14a..454dbb70 100644 --- a/apps/web/app/routes/console/subscriptions.$id.tsx +++ b/apps/web/app/routes/console/subscriptions.$id.tsx @@ -1,302 +1,8 @@ -import { ArrowLeft, Pause, Pencil, Play, RefreshCw, RotateCcw, Search } from "lucide-react"; -import { useMemo, useState } from "react"; -import { Link, useParams } from "react-router"; -import { toast } from "sonner"; -import { - useRefreshCoverage, - useSubscription, - useSubscriptionCoverage, - useSubscriptionOverviewItem, -} from "~/api/subscriptions.ts"; -import { ErrorState, LoadingState } from "~/components/console/empty-state.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 { SubscriptionMappingSummary } from "~/components/episode-mapping/subscription-summary.tsx"; -import { PRIORITY, SEASON_SCOPE, SUB_MODE, SUB_ORIGIN, SUB_STATUS } from "~/components/library/labels.ts"; -import { CoverPlaceholder, Panel, relTime, Tone } from "~/components/library/parts.tsx"; -import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx"; -import { SubscriptionMenu, useSubscriptionOps } from "~/components/subscriptions/actions.tsx"; -import { - CellSheet, - CoverageLegend, - CoverageMatrix, - type SelectedCell, -} from "~/components/subscriptions/coverage-matrix.tsx"; -import { - autoFillText, - foldNames, - GROUP_LABEL, - SUB_KIND_LABEL, - seasonLabel, - seasonSlices, - seasonTitle, -} from "~/components/subscriptions/model.ts"; -import { ProgressBlock, SlotBadges } from "~/components/subscriptions/overview-item.tsx"; -import { SlotPanel } from "~/components/subscriptions/slot-panel.tsx"; -import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { errorMessage } from "~/lib/api.ts"; -import { actorText } from "~/lib/labels.ts"; -import { cn, formatDateTime } from "~/lib/utils.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 收录版本矩阵;收录版本排序与自动补全;订阅信息。 +// 记录抽屉(console-ia.md §9):订阅详情是订阅页上的抽屉,旧地址 /console/subscriptions/:id → ?open=:id +export const clientLoader = legacyRedirectLoader; -export const meta = () => [{ title: "订阅详情 · UHD 媒体自动化控制台" }]; - -const TOTAL_SOURCE: Record = { - tmdb: "TMDB", - injected: "订阅里填写", - manual: "手动", - covered_floor: "按已整理", - previous: "沿用上次", -}; - -export default function SubscriptionDetailPage() { - const { id } = useParams(); - const subQ = useSubscription(id); - const sub = subQ.data; - const itemQ = useSubscriptionOverviewItem(sub); - const item = itemQ.data; - const covQ = useSubscriptionCoverage(id); - const cov = covQ.data; - const refresh = useRefreshCoverage(); - const ops = useSubscriptionOps(); - const [season, setSeason] = useState(0); - const [sel, setSel] = useState(null); - const [editing, setEditing] = useState(false); - - const seasonTabs = useMemo(() => { - if (!cov) return []; - const slices = seasonSlices(cov); - const any = cov.rows.find((r) => r.slotId === null); - return cov.seasons.map((s, i) => { - const sl = slices[i]!; - const have = any ? any.cells.slice(sl.start, sl.end).filter((c) => c.state === "have").length : 0; - return { - title: seasonTitle(s, cov.mediaKind), - stat: `${have}/${s.totalKnown ? s.total : "?"}`, - source: s.source ? (TOTAL_SOURCE[s.source] ?? s.source) : null, - locked: s.locked, - }; - }); - }, [cov]); - - if (subQ.isPending) return ; - if (subQ.isError || !sub) return subQ.refetch()} />; - - const title = sub.mediaEntityTitle ?? item?.mediaEntityTitle ?? sub.titleQuery ?? "订阅"; - const tracking = sub.status === "active" || sub.status === "pending"; - const ended = sub.status === "completed" || sub.status === "cancelled"; - const sLabel = item ? seasonLabel(item) : null; - const doRefresh = () => - refresh.mutate(sub.id, { - onSuccess: () => toast.success("对比已刷新"), - onError: (e) => toast.error(errorMessage(e)), - }); - const seasonIdx = Math.min(season, Math.max(0, (cov?.seasons.length ?? 1) - 1)); - - return ( - <> - - - 订阅 - -
    - -
    -
    -

    {title}

    -

    - {[item?.year, SUB_KIND_LABEL[sub.mediaKind], sLabel, SUB_MODE[sub.mode]].filter(Boolean).join(" · ")} -

    -
    -
    - - {item?.group ? {GROUP_LABEL[item.group]} : null} - {item?.upgrading ? 洗版中 : null} - {item?.awaitingNewSeason ? 等待新季 : null} - {!sub.mediaEntityId ? 不在媒体库 : null} -
    - {!sub.mediaEntityId ? ( -

    - 只按标题「{sub.titleQuery ?? title} - 」订阅,还没关联到媒体库作品,不会自动搜索和下载。先把作品加入媒体库,再从作品订阅。 -

    - ) : null} - {item ? ( -
    - - -
    - ) : null} -
    -
    - {tracking && sub.mediaEntityId ? ( - - ) : null} - {!ended ? ( - - ) : null} - {tracking ? ( - - ) : sub.status === "paused" ? ( - - ) : ( - - )} - setEditing(true)} showDetail={false} /> -
    -
    - -
    - - - 刷新对比 - - } - > - {!sub.mediaEntityId ? ( -

    作品加入媒体库后才能对比,加入后自动关联到这个订阅。

    - ) : covQ.isPending ? ( - - ) : covQ.isError ? ( - covQ.refetch()} /> - ) : !cov || cov.seasons.length === 0 ? ( -

    - 还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。 -

    - ) : ( -
    - {cov.seasons.length > 1 || cov.mediaKind !== "movie" ? ( -
    - {seasonTabs.map((t, i) => ( - - ))} -
    - ) : null} - - -
    - )} -
    - - {sub.mediaEntityId ? : null} - - {sub.mediaEntityId && sub.mediaKind !== "movie" ? ( - - ) : null} - -
    - - s.name), - 4, - ) - : null, - }, - { - label: "规则组", - value: item - ? `${foldNames( - item.ruleGroups.map((g) => g.name), - 4, - "未设置", - )}${item.defaultRuleGroups && item.ruleGroups.length ? "(未单独设置)" : ""}` - : null, - }, - { label: "自动补全", value: item ? autoFillText(item.autoFill) : null }, - { label: "上次更新", value: item ? formatDateTime(item.lastUpdatedAt) : null }, - { - label: "主动搜索", - value: sub.lastSearchedAt - ? `上次 ${relTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${relTime(sub.nextSearchAt)}` : item?.searchEnabled === false ? " · 周期搜索已关" : ""}` - : "还没搜索过", - }, - { label: "状态说明", value: sub.statusReason }, - { label: "创建", value: `${formatDateTime(sub.createdAt)} · ${actorText(sub.createdBy)}`, wide: true }, - ...(sub.mediaEntityId - ? [ - { - label: "作品", - value: ( - - 查看作品 - - ), - }, - ] - : []), - ]} - /> - - {sub.mediaEntityId ? ( - - - - ) : null} -
    -
    - - setSel(null)} - /> - {editing ? setEditing(false)} /> : null} - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/subscriptions.tsx b/apps/web/app/routes/console/subscriptions.tsx index 2140ee44..5d2a02da 100644 --- a/apps/web/app/routes/console/subscriptions.tsx +++ b/apps/web/app/routes/console/subscriptions.tsx @@ -22,6 +22,7 @@ import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty 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 { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { useQueryState } from "~/components/console/use-query-state.ts"; import { opts, SUB_ORIGIN } from "~/components/library/labels.ts"; @@ -30,6 +31,7 @@ import { SubscriptionCalendarView } from "~/components/subscriptions/calendar.ts import { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscriptions/model.ts"; import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx"; import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; +import { SubscriptionSheet } from "~/components/subscriptions/subscription-sheet.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Pagination } from "~/components/ui/pagination.tsx"; import { SegmentedControl } from "~/components/ui/segmented.tsx"; @@ -89,6 +91,7 @@ function Pills({ export default function SubscriptionsPage() { const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort", "view", "month"] as const); const v = f.values; + const sheet = useRecordSheet(); const [q, setQ] = useState(v.q); const dq = useDebounced(q.trim()); useEffect(() => { @@ -443,6 +446,7 @@ export default function SubscriptionsPage() { ids={selIds} onDone={exitSelect} /> + ); } diff --git a/apps/web/app/routes/console/tasks.$id.tsx b/apps/web/app/routes/console/tasks.$id.tsx index 9b7f0bba..4f6a6f52 100644 --- a/apps/web/app/routes/console/tasks.$id.tsx +++ b/apps/web/app/routes/console/tasks.$id.tsx @@ -1,212 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; -import { AssetOperation, DownloadTask, Task } from "@umt/contracts"; -import { ArrowLeft } from "lucide-react"; -import { Link, Navigate, useNavigate, useParams } from "react-router"; -import { z } from "zod"; -import { useEngineRun } from "~/api/engine.ts"; -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 type { StatusTone } from "~/components/console/status-badge.tsx"; -import { Timeline } from "~/components/console/timeline.tsx"; -import { - TASK_KIND_LABEL, - TaskActions, - TaskStatusLine as TaskStatusLine2, - TaskSummary, -} from "~/components/downloads/business-tasks.tsx"; -import { runTitle } from "~/components/tasks/labels.ts"; -import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { TTL_SECOND } from "~/lib/api.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }]; +// 记录抽屉(console-ia.md §9):任务详情是日志页的任务抽屉,旧地址 /console/tasks/:id → /console/logs?type=task&run=:id +// (id 是旧业务任务或下载任务时,抽屉里再换算,见 components/tasks/legacy-task.tsx) +export const clientLoader = legacyRedirectLoader; -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, enabled: boolean) { - return useQuery({ - enabled, - 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, - }))} - /> - ); -} - -const back = ( - -); - -/** - * /console/tasks/:id:id 先当引擎运行查(统一任务);查不到再当旧的业务任务 ID - * (有引擎运行就跳过去,没有就显示旧视图),最后当下载任务 ID 跳到下载详情。 - */ -export default function TaskPage() { - const { id = "" } = useParams(); - const navigate = useNavigate(); - const run = useEngineRun(id); - const runMissing = !!run.error && isMissingEndpoint(run.error); - const task = useTask(id, runMissing); - const notFound = !!task.error && isMissingEndpoint(task.error); - const legacy = useLegacyDownload(id, notFound); - - if (run.data) { - const r = run.data; - const open = (rid: string) => navigate(`/console/tasks/${encodeURIComponent(rid)}`); - return ( - <> - } - actions={ - <> - {back} - - - } - /> -
    - -
    - - ); - } - if (run.error && !runMissing) - return ( - <> - - void run.refetch()} /> - - ); - if (task.data?.engineRunId) - return ; - if (notFound && legacy.data) return ; - const t = task.data; - - return ( - <> - : undefined} - actions={ - <> - {back} - {t ? : null} - - } - /> - {run.isPending || task.isPending || (notFound && legacy.isPending) ? ( -
    - -
    - ) : !t ? ( - void task.refetch()} /> - ) : ( -
    -
    - -
    -
    - - - -
    -
    - )} - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index b1b5452c..f29d3436 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -34,7 +34,7 @@ const STATIC_ROUTES = [ "/console/files?f=video&sort=size&order=desc&hidden=1", "/console/files?mock=1", "/console/files?mock=0", - "/console/files/trash", + "/console/files?view=trash", "/console/subscriptions", "/console/subscriptions?group=pending&sort=missing", "/console/rules", @@ -83,20 +83,20 @@ const STATIC_ROUTES = [ "/console/settings#account", ]; +// 记录抽屉(docs/design/console-ia.md §9):列表里打开的记录是列表页上的抽屉(?open=);带 ?open= / ?run= 的地址要看到抽屉 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/sites/:id", list: "/api/v1/sites", after: "?tab=resources" }, + { route: "/console/organize?open=:id", list: "/api/v1/organization-records" }, + { route: "/console/sites?open=:id", list: "/api/v1/sites" }, + { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=resources" }, // 旧页签"整理视图"落到"资源" - { route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=organized" }, - { route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" }, - { route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" }, - { route: "/console/subscriptions/:id", list: "/api/v1/subscriptions" }, - { route: "/console/downloads/:id", list: "/api/v1/download-tasks" }, - { route: "/console/tasks/:id", list: "/api/v1/engine/runs" }, + { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=organized" }, + { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=snapshots" }, + { route: "/console/sites?open=:id", list: "/api/v1/sites", after: "&tab=accounts" }, + { route: "/console/subscriptions?open=:id", list: "/api/v1/subscriptions" }, + { route: "/console/downloads?open=:id", list: "/api/v1/download-tasks" }, { route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" }, - { route: "/console/logs?run=:id", list: "/api/v1/engine/runs" }, + { route: "/console/logs?type=task&run=:id", list: "/api/v1/engine/runs" }, { route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" }, ]; @@ -114,9 +114,20 @@ const REDIRECT_ROUTES = [ { from: "/console/preferences", to: "/console/rules" }, { from: "/console/preferences?origin=agent_inferred", to: "/console/rules?origin=agent_inferred" }, { from: "/console/sources?tab=snapshots", to: "/console/sites" }, - { 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 }, + { + from: "/console/sources?tab=snapshots&siteId=:id", + to: "/console/sites?open=:id&tab=snapshots", + list: "/api/v1/sites", + }, + // 记录详情的旧地址 → 列表页抽屉(§9) + { from: "/console/organize/:id", to: "/console/organize?open=:id", list: "/api/v1/organization-records" }, + { from: "/console/subscriptions/:id", to: "/console/subscriptions?open=:id", list: "/api/v1/subscriptions" }, + { from: "/console/sites/:id?tab=rss", to: "/console/sites?open=:id&tab=rss", list: "/api/v1/sites" }, + { from: "/console/downloads/:id", to: "/console/downloads?open=:id", list: "/api/v1/download-tasks" }, + { from: "/console/tasks/:id", to: "/console/logs?type=task&run=:id", list: "/api/v1/engine/runs" }, + { from: "/console/files/trash", to: "/console/files?view=trash" }, + // 旧的下载详情地址 /console/tasks/<下载任务 id>:日志页抽屉先查引擎运行和业务任务(404 属预期),再换到下载面板抽屉 + { from: "/console/tasks/:id", to: "/console/downloads?open=:id", list: "/api/v1/download-tasks", expect404: true }, // 界面第二轮:任务 / 引擎面板 / 审计 / 计划并入日志页;身份与元数据并入媒体概览(IA-3) { from: "/console/tasks?tab=business", to: "/console/logs?type=task" }, { from: "/console/tasks?tab=failed", to: "/console/logs?type=task&level=error" }, @@ -225,6 +236,12 @@ async function main() { if (new URL(page.url()).pathname === "/login") problems.push("被重定向到登录页"); const notFound = await page.locator("text=页面不存在").count(); if (notFound) problems.push("渲染成 404 页"); + if (/[?&](open|run)=/.test(path)) + await page + .locator('[data-slot="sheet-content"]') + .first() + .waitFor({ timeout: 10_000 }) + .catch(() => problems.push("没有打开记录抽屉")); } catch (err) { problems.push(`加载失败:${String(err?.message ?? err).split("\n")[0]}`); } @@ -422,7 +439,7 @@ async function main() { }); try { await page.goto("about:blank"); - await page.goto(`${BASE}/console/sites/${encodeURIComponent(site.id)}?tab=origin`, { + await page.goto(`${BASE}/console/sites?open=${encodeURIComponent(site.id)}&tab=origin`, { waitUntil: "domcontentloaded", timeout: 30_000, }); diff --git a/docs/design/console-ia.md b/docs/design/console-ia.md index 1b5255c0..60ecaa8f 100644 --- a/docs/design/console-ia.md +++ b/docs/design/console-ia.md @@ -178,10 +178,10 @@ | 类型 | 规范 | 现有组件 | | --- | --- | --- | -| 列表页 | `PageHeader`(标题 + 一句描述 + 主操作)→ `FilterBar`(搜索、状态、对象筛选,状态写入 URL query)→ `DataTable`(行点击开 `DetailSheet`,不跳页)→ 分页 | `page-header` `filter-bar` `data-table` `detail-sheet` 均已有 | +| 列表页 | `PageHeader`(标题 + 一句描述 + 主操作)→ `FilterBar`(搜索、状态、对象筛选,状态写入 URL query)→ `DataTable`(行点击开 `DetailSheet`,不跳页,见 §9)→ 分页 | `page-header` `filter-bar` `data-table` `detail-sheet` 均已有 | | 详情页 | 统一头部:标题 + `StatusBadge` + 主操作 1 个 + 次操作收进"更多";下方 `DetailSection` 分节,节顺序:概况 → 进度/状态 → 关联对象 → 历史/证据 | `status-badge` `kv-list` `timeline` | | 跨页关联 | 任何对象名都可点,点开同一个 `DetailSheet` 或深链到其"家"页面 | — | -| 设置 | **左侧分区导航(二级路由)+ 分区内锚点 + 分节表单**,每节独立保存 | 复用 `SettingsCard` / `resource-section` | +| 设置 | **左侧分区导航 + 分区内锚点 + 分节表单**,每节独立保存;左栏只有分区一层(§9.1) | 复用 `SettingsCard` / `resource-section` | 设置用"左侧分区 + 锚点"而不是 MP 的顶部 Tab,理由: 1. 9 个分区在顶部 Tab 会横向溢出(MP 靠 `show-arrows`,现有 9 Tab 在窄屏已挤);左栏能放每区一句描述,等同 MP 的 `settingTabs.*.description`。 @@ -383,5 +383,61 @@ W10 已补(2026-09-28): ## 8. 界面第二轮:系统组只留日志、运行状态、设置(2026-10-06) - 侧栏 4 组 13 项:概览(首页、智能体会话)/ 获取 / 整理 / 系统(日志、运行状态、设置)。任务、审计、引擎面板、计划不再有入口。 -- 日志页 `/console/logs`:任务、定时与自动、登录、审计合成一份列表,可实时追加、勾选后问智能体;第二个页签「定时任务」是原计划页。旧地址:`/console/tasks`(含 `?tab=` `?status=` `?kind=&ref=` `?run=`)、`/console/engine(/:id)`、`/console/audit`(`?targetType=&targetId=` → 对象筛选)、`/console/schedules(?job=)` 都跳到日志页对应筛选;`/console/tasks/:id` 独立页保留,侧栏高亮「日志」。 +- 日志页 `/console/logs`:任务、定时与自动、登录、审计合成一份列表,可实时追加、勾选后问智能体;第二个页签「定时任务」是原计划页。旧地址:`/console/tasks`(含 `?tab=` `?status=` `?kind=&ref=` `?run=`)、`/console/engine(/:id)`、`/console/audit`(`?targetType=&targetId=` → 对象筛选)、`/console/schedules(?job=)` 都跳到日志页对应筛选;`/console/tasks/:id` 起初保留为独立页,2026-10-08 起改成日志页的任务抽屉(§9)。 - 运行状态页加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势),首页「存储情况」卡换成运行状态简版卡。细节见 `docs/runbook/logs.md`。 + +## 9. 导航层级与记录抽屉(2026-10-08,现行标准) + +控制台怎么分层、从列表打开一条记录怎么展示,以这一节为准;[控件标准](ui-controls.md)只引用这里,不另写一份。 + +### 9.1 最多两层 + +- 侧栏一项 = 一个页面。页面里最多再有**一层**切换(页签或分段控件),不再往下嵌第三层。 +- 筛选、视图(卡片 / 列表 / 日历)、图表的时间范围是同一页的显示方式,不算一层导航。 +- 设置页左栏只有一层:分区。节不出现在左栏和窄屏的下拉下面,是页面里的小标题;搜索与 `#分区/节` 深链仍能滚到节和字段。 + +### 9.2 从列表打开记录 = 右侧抽屉 + +- 列表 / 卡片里点一条(看详情、编辑、排查)打开右侧抽屉 `DetailSheet`(`components/console/detail-sheet.tsx`),列表留在原处,滚动位置和筛选都不动。抽屉材质是 `pg-float`,手机上全宽。 +- 宽度:`md`(约 32rem,简单记录)、`lg`(约 44rem,有时间线或文件明细)、`xl`(约 64rem,内容重的:订阅对比、站点)。 +- 打开的记录写在地址栏 `?open=`,其余参数照留;刷新、分享链接、前进后退都能还原。抽屉里的页签等只属于抽屉的参数(站点的 `?tab=`)随抽屉走,关掉时一起去掉。 +- 在本页打开是 push,所以浏览器后退就是关抽屉;关闭按钮 / Esc 在本页打开时等于后退一步,从别处链接直接进来时 replace 去掉参数、留在列表。抽屉开着时换一条记录是 replace。 +- 不叠第二层抽屉。抽屉里再打开的详情(站点里的资源、RSS,订阅里的格子)在同一个抽屉里换成子视图,顶部「返回」(Esc 也是先返回);需要更多空间就链到对象自己的页面。 +- 动作表单(新建、从「⋯」菜单打开的编辑表单、确认)是对话框性质,可以不进地址栏。 +- 实现:列表页用 `useRecordSheet()`,链接用 ``,代码里跳转用 `useOpenRecord()`,别处拼地址用 `recordPath(page, id)`(都在 `components/console/record-sheet.tsx`)。日志页的记录参数是 `?log=<日志 id>`,任务也可以只带 `?run=<引擎运行 id>`(`taskPath(id)`),规则同上。 + +### 9.3 独立页面只给要待一阵子的工作对象 + +- 作品:`/console/library/:id`、`/console/media/tmdb/:type/:id`。 +- 智能体会话:`/console/agent/:runId`。 +- 其余对象(整理记录、订阅、站点、下载、任务、规则组、外部身份、未识别资源、设置里的实例)都是所属列表页上的抽屉。 + +### 9.4 旧地址 + +`routes/console/redirects.ts` 把旧的详情页地址换到列表页抽屉,其余参数跟在后面: + +| 旧 | 新 | +| --- | --- | +| `/console/organize/:id` | `/console/organize?open=:id` | +| `/console/subscriptions/:id` | `/console/subscriptions?open=:id` | +| `/console/sites/:id(?tab=…)` | `/console/sites?open=:id(&tab=…)` | +| `/console/downloads/:id` | `/console/downloads?open=:id`(`/downloads/plans(/:id)` 仍按 §7 跳 `?plan=`) | +| `/console/tasks/:id` | `/console/logs?type=task&run=:id`;id 是旧业务任务时抽屉里换成它的引擎运行或显示旧任务概况,是下载任务时换到 `/console/downloads?open=:id` | +| `/console/files/trash` | `/console/files?view=trash`(回收站是文件管理右侧的一个视图,左栏「回收站」切过去) | +| `/console/sources?siteId=x` | `/console/sites?open=x&tab=rss` | + +站内链接、铃铛、命令面板、任务时间线、整理队列都直接拼新地址;智能体会话里的链接与服务端 `agent/challenge.ts` 生成的地址还是旧格式,靠上表跳转。 + +### 9.5 现状对照 + +| 页面 | 处理 | +| --- | --- | +| 整理记录 | 行点开 `lg` 抽屉:状态、操作、路径、文件明细、整理后刮削;队列面板里的「整理记录」换成记录抽屉 | +| 订阅 | 卡片 / 列表 / 日历点开 `xl` 抽屉,覆盖对比的格子在同一抽屉里换成子视图 | +| 站点 | 卡片 / 列表点开 `xl` 抽屉,里面一层页签(概览、资源、原站视图、抓取、RSS、账号);资源、RSS 详情是子视图 | +| 下载面板 | 行点开 `lg` 抽屉,`?open=` 是下载任务或计划 id;不在当前分段里的也能打开 | +| 日志 | 行点开抽屉(`?log=` / `?run=`),不再有「打开任务页」 | +| 文件管理 | 文件详情原本就是 `?sel=`;回收站并进右侧视图 | +| 规则 | 规则组编辑、「自动收新作品」的策略版本查看走 `?open=` | +| 媒体库 | 「未识别」「外部身份」的行抽屉走 `?open=`;作品仍是独立页 | +| 设置 | 左栏只列分区;各分区里实例列表(下载器、媒体服务器、媒体库目录等)的编辑抽屉走 `?open=` | diff --git a/docs/design/ui-controls.md b/docs/design/ui-controls.md index dba431ec..f5983f98 100644 --- a/docs/design/ui-controls.md +++ b/docs/design/ui-controls.md @@ -28,6 +28,7 @@ - **页头与工具栏**:一屏一个主操作,其余是描边胶囊;工具栏里的图标按钮是描边圆钮(`variant="outline" size="icon-sm"`)。`ghost` 只留给行内文字动作(返回链接、表格行尾的「⋯」、卡片里的「详情」「清除选择」)和顶栏胶囊里的按钮。下拉一律用 `Select`,不用原生 `