feat(web): 记录抽屉与两层导航标准

控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 14:03:47 +08:00
1 parent dd0f6e2c09
commit 21c55dfb3a
60 files changed
+2768 -2461

No files matched your search

+1 -1
View File
@@ -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", "下载器"]),
+3 -2
View File
@@ -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),
});
}
+136 -23
View File
@@ -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<DetailSheetWidth, string> = {
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<Nest | null>(null);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
/** 标题下面一行的操作按钮(记录抽屉的主操作、更多) */
actions?: ReactNode;
children: ReactNode;
footer?: ReactNode;
width?: DetailSheetWidth;
className?: string;
};
/**
* 右侧详情抽屉(手机全宽)。内容区自带纵向滚动;footer 固定在底部放操作按钮。
* <DetailSheet open={!!id} onOpenChange={(o) => !o && setId(null)} title="详情" description="…">
* <KvList items={…} />
* </DetailSheet>
* 从列表打开记录时配合 useRecordSheet(record-sheet.tsx)把打开的记录写进地址栏。
*/
export function DetailSheet({
export function DetailSheet(props: Props) {
const nest = useContext(NestContext);
if (nest) return <NestedView {...props} nest={nest} />;
return <RootSheet {...props} />;
}
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<HTMLDivElement | null>(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<Nest>(() => ({ host, push }), [host, push]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)}
className={cn("w-full gap-0 p-0", WIDTH[width], className)}
onEscapeKeyDown={(e) => {
// 有子视图时 Esc 先回到上一层
const top = stack.current.at(-1);
if (top) {
e.preventDefault();
top();
}
}}
>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
{description ? <SheetDescription className="break-words">{description}</SheetDescription> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{footer}
<NestContext.Provider value={nest}>
<div className={cn("flex min-h-0 flex-1 flex-col", depth > 0 && "invisible")} aria-hidden={depth > 0}>
<SheetFrame title={title} description={description} actions={actions} footer={footer}>
{children}
</SheetFrame>
</div>
) : null}
<div ref={setHost} className={cn("absolute inset-0 flex flex-col", depth === 0 && "hidden")} />
</NestContext.Provider>
</SheetContent>
</Sheet>
);
}
function SheetFrame({
title,
description,
actions,
footer,
children,
back,
}: Pick<Props, "title" | "description" | "actions" | "footer" | "children"> & { back?: () => void }) {
return (
<>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
{back ? (
<Button variant="ghost" size="sm" className="-ml-2 mb-1 self-start" onClick={back}>
<ArrowLeft />
返回
</Button>
) : null}
{back ? (
// 子视图在同一个对话框里,标题不再占用对话框的 Title / Description
<>
<h2 className="line-clamp-2 text-lead font-semibold break-words text-foreground">{title}</h2>
{description ? <div className="text-sm break-words text-muted-foreground">{description}</div> : null}
</>
) : (
<>
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
{description ? (
<SheetDescription asChild>
<div className="break-words">{description}</div>
</SheetDescription>
) : null}
</>
)}
{actions ? <div className="mt-2 flex flex-wrap items-center gap-2">{actions}</div> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{footer}
</div>
) : 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(
<div className="flex min-h-0 flex-1 flex-col">
<SheetFrame {...frame} back={() => close.current()} />
</div>,
host,
);
}
/** 抽屉/卡片内的小节:大写小标签 + 内容 */
export function DetailSection({
title,
@@ -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<Decision, "subjectType" | "subjectId" | "ag
if (d.agentRunId) return consolePath("agent", d.agentRunId);
if (d.subjectType === "acquisition_plan" && d.subjectId)
return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
if (d.subjectType === "download_task" && d.subjectId) return consolePath("downloads", d.subjectId);
if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId);
return consolePath("agent");
}
+27 -4
View File
@@ -10,6 +10,7 @@ import {
pageTitle,
settingsPath,
} from "./nav.ts";
import { recordPath, taskPath } from "./record-sheet.tsx";
/** routes.ts 里登记的控制台路由(相对 /console) */
const ROUTE_PATTERNS = [
@@ -69,7 +70,7 @@ describe("控制台导航", () => {
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();
@@ -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=<id>(其余参数不动),刷新、分享、前进后退都能还原;
// 在本页打开是 push(后退关抽屉),关闭时如果是本页 push 的就后退一步,否则 replace 去掉参数。
/** 有记录抽屉的列表页 */
export type RecordPage = "organize" | "subscriptions" | "sites" | "downloads";
export const OPEN_PARAM = "open";
type Extra = Record<string, string | null | undefined>;
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/<page>?open=<id>[&…] */
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=<id> */
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<LinkProps, "to" | "state" | "replace"> & { page: RecordPage; id: string; extra?: Extra }) {
const location = useLocation();
const t = recordTarget(location, page, id, extra);
return <Link {...rest} to={t.to} state={t.state} replace={t.replace} preventScrollReset={t.preventScrollReset} />;
}
/** 代码里跳到一条记录(菜单项、按钮) */
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"] });
* <DataTable onRowClick={(r) => sheet.open(r.id)} selectedKey={sheet.id} />
* <DetailSheet open={!!sheet.id} onOpenChange={(o) => !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;
}
@@ -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");
@@ -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(
"磁盘已满",
);
@@ -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) };
@@ -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<TaskKind, string> = {
ingestFeed: "抓取 RSS",
@@ -71,15 +67,15 @@ export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): 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<string | null>(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<Task>[] = [
{
key: "kind",
header: "任务",
cell: (t) => (
<div className="min-w-0">
<p className="font-medium text-foreground">{TASK_KIND_LABEL[t.kind] ?? t.kind}</p>
<p className="truncate text-meta text-muted-foreground">{subjectText(t)}</p>
</div>
),
},
{
key: "status",
header: "状态",
cell: (t) => (
<div className="flex flex-col items-start gap-1">
<StatusBadge tone={TASK_STATUS[t.status].tone}>{TASK_STATUS[t.status].label}</StatusBadge>
{t.waitReason ? <span className="text-micro text-muted-foreground">{t.waitReason}</span> : null}
</div>
),
},
{
key: "attempts",
header: "尝试",
hideBelow: "md",
align: "right",
cell: (t) => (
<span className="tabular-nums">
{t.attempts}/{t.maxAttempts}
</span>
),
},
{
key: "by",
header: "触发",
hideBelow: "lg",
cell: (t) => <span className="text-muted-foreground">{actorText(t.triggeredBy)}</span>,
},
{
key: "at",
header: "创建",
hideBelow: "sm",
cell: (t) => <span className="whitespace-nowrap text-muted-foreground">{relTime(t.createdAt)}</span>,
},
{ key: "ops", header: "", align: "right", cell: (t) => <TaskActions t={t} size="xs" /> },
];
return (
<>
<FilterBar onReset={f.active ? f.reset : undefined}>
<FilterSelect
label="类型"
value={kind}
onChange={(v) => f.set("kind", v)}
options={Object.entries(TASK_KIND_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="状态"
value={status}
onChange={(v) => f.set("status", v)}
options={Object.entries(TASK_STATUS).map(([value, v]) => ({ value, label: v.label }))}
/>
</FilterBar>
<DataTable
paginationKey={JSON.stringify(f.values)}
columns={columns}
rows={rows}
rowKey={(t) => 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 : "提交下载、核验整理、清理缓存、文件核对等任务会出现在这里。",
}}
/>
<DetailSheet
open={!!open}
onOpenChange={(o) => !o && setOpenId(null)}
title={open ? (TASK_KIND_LABEL[open.kind] ?? open.kind) : ""}
description={open ? <TaskStatusLine t={open} /> : undefined}
footer={
open ? (
<>
<TaskActions t={open} />
<Button asChild size="sm">
<Link to={consolePath("tasks", open.id)}>打开详情页</Link>
</Button>
</>
) : null
}
>
{open ? <TaskSummary t={open} /> : null}
</DetailSheet>
</>
);
}
export function TaskStatusLine({ t }: { t: Task }) {
return (
<span className="flex flex-wrap items-center gap-1.5">
@@ -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 ? (
<Link to={consolePath("tasks", t.parentTaskId)} className="font-mono underline underline-offset-4">
<Link to={taskPath(t.parentTaskId)} className="font-mono underline underline-offset-4">
{t.parentTaskId.slice(0, 8)}
</Link>
) : null,
@@ -330,7 +202,7 @@ export function TaskSummary({ t }: { t: Task }) {
{
label: "引擎运行",
value: t.engineRunId ? (
<Link to={consolePath("tasks", t.engineRunId)} className="font-mono underline underline-offset-4">
<Link to={taskPath(t.engineRunId)} className="font-mono underline underline-offset-4">
{t.engineRunId.slice(0, 12)}
</Link>
) : null,
@@ -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)),
)
@@ -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 ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
整理失败:{reasonLine(importFailed.reason)}。
<Link to={`/console/organize/${importFailed.id}`} className="ml-1 underline underline-offset-4">
<RecordLink page="organize" id={importFailed.id} className="ml-1 underline underline-offset-4">
去整理记录处理
</Link>
</RecordLink>
</p>
) : null}
{task.noProgressSince ? (
+6 -5
View File
@@ -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) => (
<p key={d.taskId}>
下载任务{" "}
<Link className="underline hover:text-foreground" to={`/console/downloads/${d.taskId}`}>
<RecordLink className="underline hover:text-foreground" page="downloads" id={d.taskId}>
{d.taskId.slice(0, 8)}
</Link>{" "}
</RecordLink>{" "}
· {d.status} · H&R{" "}
{d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}
</p>
@@ -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: (
<Link className="hover:underline" to={`/console/organize/${r.id}`}>
<RecordLink className="hover:underline" page="organize" id={r.id}>
{ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]}
</Link>
</RecordLink>
),
description: r.target.path ? <span className="font-mono text-meta break-all">{r.target.path}</span> : null,
meta: `${r.counts.succeeded}/${r.counts.total}`,
@@ -185,9 +186,9 @@ export function FileDetail({
{
label: "任务",
value: (
<Link className="underline underline-offset-2" to={`/console/downloads/${e.download.taskId}`}>
<RecordLink className="underline underline-offset-2" page="downloads" id={e.download.taskId}>
{downloadLabel(e.download.status).label}
</Link>
</RecordLink>
),
},
{
+5 -3
View File
@@ -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 (
<span className="inline-flex min-w-0 items-center gap-1.5" title={v.hint}>
{!compact && e.download ? (
<Link
to={`/console/downloads/${e.download.taskId}`}
<RecordLink
page="downloads"
id={e.download.taskId}
onClick={(ev) => ev.stopPropagation()}
className="min-w-0 truncate hover:underline"
title={`${v.hint ?? v.label} · 打开下载任务`}
>
{badge}
</Link>
</RecordLink>
) : (
badge
)}
@@ -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({
</StatusBadge>
) : null}
{it.records.lastRecordId ? (
<Link className="underline underline-offset-2" to={`/console/organize/${it.records.lastRecordId}`}>
<RecordLink className="underline underline-offset-2" page="organize" id={it.records.lastRecordId}>
查看
</Link>
</RecordLink>
) : null}
</p>
) : null}
@@ -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<Set<string>>(new Set());
const [restoreTo, setRestoreTo] = useState<string[] | null>(null);
const [purgeAll, setPurgeAll] = useState(false);
const [purgeIds, setPurgeIds] = useState<string[] | null>(null);
const [confirmText, setConfirmText] = useState("");
/** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */
const [failedIds, setFailedIds] = useState<Map<string, { code: string; message: string }>>(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<TrashItem>[] = [
{
key: "pick",
header: (
<Checkbox
checked={!!rows?.length && picked.size === rows.length}
partial={picked.size > 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) => (
<Checkbox checked={picked.has(r.id)} label={`选择 ${baseName(r.originalPath)}`} onToggle={() => toggle(r.id)} />
),
},
{
key: "name",
header: "名称",
cell: (r) => {
const fail = failedIds.get(r.id);
return (
<div className="flex min-w-0 max-w-md items-start gap-2">
{r.isDir ? (
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="truncate text-foreground" title={r.trashPath}>
{baseName(r.originalPath)}
</p>
{fail ? (
<p className="text-meta text-destructive">
{fail.code === "exists" || fail.code === "conflict"
? "原位置已被占用"
: fail.code === "too_large"
? "跨卷还原超过大小上限"
: fail.message}
</p>
) : null}
</div>
</div>
);
},
},
{
key: "origin",
header: "原位置",
hideBelow: "md",
cell: (r) => {
const root = rootName.get(r.rootId);
const dir = parentRel(r.originalPath);
return root ? (
<Link
to={`/console/files?root=${encodeURIComponent(r.rootId)}${dir ? `&path=${encodeURIComponent(dir)}` : ""}`}
className="block max-w-xs truncate font-mono text-meta text-muted-foreground hover:text-foreground hover:underline"
title={`${root.hostPath}/${r.originalPath}`}
>
{dir ? `${root.name}/${dir}` : root.name}
</Link>
) : (
<span className="block max-w-xs truncate font-mono text-meta text-muted-foreground">{r.rootId}</span>
);
},
},
{
key: "size",
header: "大小",
hideBelow: "sm",
align: "right",
cell: (r) => <span className="whitespace-nowrap">{formatBytes(r.size, 1)}</span>,
},
{
key: "deleted",
header: "删除",
hideBelow: "md",
cell: (r) => (
<span
className="block max-w-40 text-muted-foreground"
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
>
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
{r.actor ? <span className="block truncate text-meta">{actorText(r.actor)}</span> : null}
</span>
),
},
{
key: "expires",
header: "到期清除",
cell: (r) => (
<span title={formatDateTime(r.expiresAt)}>
<StatusBadge tone={expiryTone(r.expiresAt)}>
{Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)}
</StatusBadge>
</span>
),
},
{
key: "asset",
header: "媒体库记录",
hideBelow: "lg",
cell: (r) => {
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
return (
<span className="block max-w-48 text-meta" title="还原后不会回到媒体库,需要时重新整理">
{snap.mediaEntityId ? (
<Link className="underline underline-offset-2" to={consolePath("library", snap.mediaEntityId)}>
{label}
</Link>
) : (
label
)}
<span className="block text-muted-foreground">已移出媒体库</span>
</span>
);
},
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<div className="flex justify-end gap-1">
{failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? (
<Button size="xs" variant="outline" onClick={() => setRestoreTo([r.id])}>
<RotateCcw />
还原到…
</Button>
) : (
<>
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
<RotateCcw />
还原
</Button>
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
还原到…
</Button>
</>
)}
{isAdmin ? (
<Button
size="xs"
variant="ghost"
aria-label="立即清除"
title="立即清除"
onClick={() => setPurgeIds([r.id])}
>
<Trash2 />
</Button>
) : null}
</div>
),
},
];
return (
<>
<div className="mb-3 flex min-w-0 items-start gap-2">
{leading}
<div className="min-w-0 flex-1">
<h2 className="flex items-center gap-2 text-body font-semibold text-foreground">
回收站
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
</h2>
<p className="text-caption text-muted-foreground">
到期后自动清除,到期前可以还原。还原不会恢复媒体库记录,需要时重新整理。
</p>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={!picked.size || restore.isPending}
onClick={() => doRestore([...picked])}
>
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
{picked.size ? `还原 ${picked.size} 项` : "还原选中"}
</Button>
{isAdmin ? (
<>
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
<Trash2 />
{picked.size ? `清除 ${picked.size} 项` : "清除选中"}
</Button>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" disabled={!expired.length}>
<Clock />
清除已到期 {expired.length || ""}
</Button>
}
title="清除已到期的项?"
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
confirmLabel="清除"
destructive
onConfirm={() => doPurge(undefined)}
/>
<Button
size="sm"
variant="destructive"
className="ml-auto"
disabled={!rows?.length}
onClick={() => setPurgeAll(true)}
>
<Trash />
清空回收站
</Button>
</>
) : (
<span className="ml-auto text-meta text-muted-foreground">立即清除需要管理员会话。</span>
)}
</div>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => 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
}
/>
<ConfirmDialog
open={!!purgeIds}
onOpenChange={(o) => !o && setPurgeIds(null)}
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
description="清除后不可恢复。"
confirmLabel="清除"
destructive
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
/>
{/* 清空全部:二次确认,需输入“清空” */}
<ConfirmDialog
open={purgeAll}
onOpenChange={(o) => {
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(""));
}}
>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 清空"
aria-label="输入 清空 以确认"
/>
</ConfirmDialog>
{restoreTo ? (
<RestoreToDialog
ids={restoreTo}
onClose={() => 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<FileRef | null>(null);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>还原到…</DialogTitle>
<DialogDescription>{ids.length} 项 · 选一个可写的媒体库目录。同名项已存在时不覆盖。</DialogDescription>
</DialogHeader>
<DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button disabled={!target || pending} onClick={() => target && onPick(target)}>
{pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
还原到这里
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<string | null>(null);
// 记录抽屉(console-ia.md §9):?open=<id>
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}
/>
<PoolSheet key={open?.id ?? "none"} item={open} onClose={() => setOpenId(null)} />
<PoolSheet key={open?.id ?? "none"} item={open} onClose={sheet.close} />
</>
);
}
@@ -157,7 +160,9 @@ export function ExternalTab() {
provider: provider || undefined,
status: (status || undefined) as IdentityStatus | undefined,
});
const [openId, setOpenId] = useState<string | null>(null);
// 记录抽屉(console-ia.md §9):?open=<id>
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}
/>
<ExternalIdentitySheet item={open} onClose={() => setOpenId(null)} />
<ExternalIdentitySheet item={open} onClose={sheet.close} />
</>
);
}
@@ -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<string | null>(null);
// 记录抽屉(console-ia.md §9):查看的策略版本写在 ?open=<id>
const sheet = useRecordSheet();
const viewId = sheet.id;
const groups = useMemo(() => {
const m = new Map<string, PolicyRevision[]>();
@@ -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}
<PolicyDetailSheet
rev={viewing}
onClose={() => 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 });
}}
/>
+3 -4
View File
@@ -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 ? (
<Link to={consolePath("downloads", v.downloadTaskId)} className="underline-offset-2 hover:underline">
<RecordLink page="downloads" id={v.downloadTaskId} className="underline-offset-2 hover:underline">
查看下载任务
</Link>
</RecordLink>
) : null,
},
].filter((it) => it.value !== null && it.value !== undefined && it.value !== "");
+5 -15
View File
@@ -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 <LegacyTaskSheet id={runId} runError={q.error} onOpenRun={onOpenRun} onClose={onClose} />;
return (
<DetailSheet
open
@@ -237,25 +239,13 @@ function RunSheet({
footer={
r ? (
<>
<Button asChild variant="ghost" size="sm">
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
<ExternalLink />
打开任务页
</Link>
</Button>
{entry ? <AskAgentButton entries={[entry]} /> : null}
<RunActions r={r} onOpenRun={onOpenRun} />
</>
) : null
}
>
{q.isPending ? (
<LoadingState rows={6} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : r ? (
<TaskDetailBody r={r} onOpenRun={onOpenRun} />
) : null}
{q.isPending ? <LoadingState rows={6} /> : r ? <TaskDetailBody r={r} onOpenRun={onOpenRun} /> : null}
</DetailSheet>
);
}
@@ -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({
: ""}
</span>
{result && g.recordId ? (
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${g.recordId}`}>
<RecordLink className="ml-auto text-meta underline underline-offset-2" page="organize" id={g.recordId}>
整理记录
</Link>
</RecordLink>
) : null}
</header>
<div className="space-y-2 px-3 py-2.5 empty:hidden">
@@ -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}
</span>
) : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
<Link className="ml-auto hover:text-foreground hover:underline" to={taskPath(run.id)}>
任务详情
</Link>
{record?.organizationRecordId ? (
<Link
<RecordLink
className="hover:text-foreground hover:underline"
to={`/console/organize/${record.organizationRecordId}`}
page="organize"
id={record.organizationRecordId}
extra={{ queue: null }}
>
整理记录
</Link>
</RecordLink>
) : null}
</div>
{detail.error || scrape.error ? (
@@ -116,16 +116,18 @@ function QueueItem({ run }: { run: EngineRun }) {
</span>
) : null}
{run.attempts > 1 ? <span>第 {run.attempts} 次尝试</span> : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
<Link className="ml-auto hover:text-foreground hover:underline" to={taskPath(run.id)}>
任务详情
</Link>
{record ? (
<Link className="hover:text-foreground hover:underline" to={`/console/organize/${record.id}`}>
<RecordLink
className="hover:text-foreground hover:underline"
page="organize"
id={record.id}
extra={{ queue: null }}
>
整理详情
</Link>
</RecordLink>
) : null}
</div>
{detail.error || recordQuery.error || related.error || download.error ? (
@@ -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<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "整理中", tone: "neutral" },
succeeded: { label: "成功", tone: "success" },
failed: { label: "失败", tone: "destructive" },
skipped: { label: "已跳过", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
const ROLE_LABEL = {
main: "主视频",
subtitle: "字幕",
audio: "音轨",
bluray_dir: "原盘目录",
nfo: "NFO",
image: "图片",
other: "其他",
} as const;
/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
const noTarget = failed && p.path === null;
return (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">{label}</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
</p>
</div>
);
}
function duration(a: string | null, b: string | null) {
if (!a || !b) return null;
const s = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
return s < 60 ? `${s.toFixed(1)} 秒` : `${Math.round(s / 60)} 分钟`;
}
function FileRow({ f }: { f: PublicOrganizationFile }) {
const [open, setOpen] = useState(false);
const st = FILE_STATUS[f.status];
const reason = f.failure ?? f.skipReason;
return (
<li className="min-w-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
<span className="shrink-0 text-meta text-muted-foreground">{ROLE_LABEL[f.role]}</span>
<span className="min-w-0 flex-1 truncate font-mono text-meta text-foreground">
{(f.target.path ?? f.source.path ?? "").split("/").pop()}
</span>
{f.sizeBytes !== null ? (
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
{formatBytes(f.sizeBytes, 1)}
</span>
) : null}
</button>
{open ? (
<div className="space-y-2 px-3 pb-3 pl-9">
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={f.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<PathBlock label="整理后" p={f.target} failed={f.status === "failed"} />
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
</p>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(reason) },
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
{ label: "文件结果 ID", value: f.id },
]}
/>
</div>
) : null}
</li>
);
}
/**
* 整理记录抽屉(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 (
<>
<DetailSheet
open={!!id}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={r?.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : r ? "未关联作品" : "整理记录"}
description={
r ? (
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
<span className="tabular-nums">
{[
`${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(" · ")}
</span>
</span>
) : undefined
}
actions={
r ? (
<>
{canReorganize(r) ? (
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
<Wand2 />
重新整理
</Button>
) : null}
{r.status === "failed" || r.status === "partial" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
openAgentDrawer({
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
recordId: r.id,
entityId: r.media?.entityId,
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
})
}
>
<Bot />
让智能体排查
</Button>
) : null}
{r.media ? (
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("library", r.media.entityId)}>查看作品</Link>
</Button>
) : null}
{!isRunning(r) ? (
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setRemoving(true)}>
<Trash2 />
删除
</Button>
) : null}
</>
) : null
}
>
{rec.isPending ? (
<LoadingState rows={5} />
) : rec.error || !r ? (
<ErrorState plain error={rec.error} onRetry={() => void rec.refetch()} />
) : (
<RecordBody r={r} onReorganize={() => setReorganize(true)} />
)}
</DetailSheet>
{r ? (
<DeleteRecordsDialog
records={[r]}
open={removing}
onOpenChange={setRemoving}
onDone={(removed) => {
if (removed.includes(r.id)) onClose();
}}
/>
) : null}
{reorganize && r ? (
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
) : null}
</>
);
}
function RecordBody({
r,
onReorganize,
}: {
r: NonNullable<ReturnType<typeof useRecord>["data"]>;
onReorganize: () => void;
}) {
const progress = recordFileProgress(r);
return (
<>
<section className="space-y-4">
{progress ? (
<p className="break-all text-caption text-muted-foreground">
已完成 {progress.completed}/{progress.total} 个文件
{progress.current ? ` · 当前文件:${progress.current}` : ""}
</p>
) : null}
{r.reason ? (
<p
className={cn(
"rounded-lg border px-3 py-2 text-caption break-words",
r.status === "failed"
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-warning/40 bg-warning/10 text-warning",
)}
>
{reasonLine(r.reason)}
</p>
) : null}
{r.attention ? (
<div className="space-y-2 rounded-lg border border-warning/40 px-3 py-2">
<p className="text-caption text-foreground">{attentionLine(r) ?? "这条记录需要处理"}</p>
<RecordAttentionActions r={r} onReorganize={onReorganize} />
</div>
) : null}
<div className="flex flex-col gap-2">
<PathBlock label="整理前" p={r.source} />
<PathBlock label="整理后" p={r.target} failed={r.status === "failed"} />
</div>
<KvList
items={[
{ label: "开始", value: formatDateTime(r.startedAt) },
{ label: "完成", value: formatDateTime(r.finishedAt) },
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
...(r.download
? [
{
label: "下载任务",
value: `${r.download.name}${r.download.downloaderName ? ` · ${r.download.downloaderName}` : ""}`,
},
]
: []),
]}
/>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(r.reason) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
{
label: "外部编号",
value: r.media?.externalIds.map((e) => `${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 },
]}
/>
</section>
<DetailSection title={`文件明细(${r.files?.length ?? 0})`}>
{r.files?.length ? (
<PaginatedList
rows={r.files}
label="整理文件明细"
resetKey={r.id}
as="ul"
className="divide-y divide-border/60 rounded-lg border border-border/60"
>
{(f) => <FileRow key={f.id} f={f} />}
</PaginatedList>
) : (
<p className="text-caption text-muted-foreground">
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
</p>
)}
</DetailSection>
<OrganizeScrapeHistory recordId={r.id} />
</>
);
}
@@ -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 (
<section className="pg-slab space-y-3 rounded-xl p-4" aria-label="整理后刮削">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">整理后刮削</h2>
<DetailSection title="整理后刮削">
{query.isPending ? (
<LoadingState rows={1} />
) : query.error ? (
@@ -79,10 +80,7 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
</details>
) : null}
{record.taskId ? (
<Link
to={`/console/tasks/${encodeURIComponent(record.taskId)}`}
className="inline-block text-meta underline"
>
<Link to={taskPath(record.taskId)} className="inline-block text-meta underline">
查看刮削任务
</Link>
) : null}
@@ -92,6 +90,6 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
) : (
<p className="text-caption text-muted-foreground">没有刮削记录。</p>
)}
</section>
</DetailSection>
);
}
@@ -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: () =>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">
<Link to={`/console/sites/${r.siteId}`} title="打开站点页">
<RecordLink page="sites" id={r.siteId} title="打开站点">
<ExternalLink />
站点页
</Link>
站点
</RecordLink>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
@@ -8,6 +8,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { useRecordSheet } from "~/components/console/record-sheet.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { UnitInput } from "~/components/console/unit-input.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -111,9 +112,14 @@ export function ResourceSection<Row extends { id: string }>({
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
}) {
const [editing, setEditing] = useState<Row | "new" | null>(null);
const [deleting, setDeleting] = useState<Row | null>(null);
const { list, remove } = hooks;
// 记录抽屉(console-ia.md §9):编辑哪一个写在 ?open=<id>(同页几个列表各认自己的 id);新建只在本页状态里
const sheet = useRecordSheet();
const [creating, setCreating] = useState(false);
const found = sheet.id ? (list.data?.items.find((r) => r.id === sheet.id) ?? null) : null;
const editing: Row | "new" | null = creating ? "new" : found;
const setEditing = (row: Row | "new") => (row === "new" ? setCreating(true) : sheet.open(row.id));
const columns: DataColumn<Row>[] = [
...config.columns,
@@ -174,7 +180,7 @@ export function ResourceSection<Row extends { id: string }>({
config={config}
hooks={hooks}
row={editing}
onClose={() => setEditing(null)}
onClose={() => (creating ? setCreating(false) : sheet.close())}
/>
<ConfirmDialog
open={!!deleting}
@@ -8,7 +8,6 @@ import {
} from "@umt/contracts";
import { ArrowLeft, Captions, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
useCreateSite,
@@ -20,7 +19,7 @@ import {
} from "~/api/sites.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { useOpenRecord } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
@@ -59,7 +58,7 @@ function defaultKind(kinds: LoginKind[]): LoginKind {
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const openRecord = useOpenRecord();
const startKind = initialKind && KIND_ORDER.includes(initialKind) ? initialKind : null;
const [kind, setKind] = useState<SiteKind | null>(startKind);
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
@@ -120,7 +119,7 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
});
toast.success(`已添加「${site.name}」`);
onClose();
navigate(consolePath("sites", site.id));
openRecord("sites", site.id);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
@@ -257,7 +256,7 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
const finish = () => {
onClose();
if (created) navigate(consolePath("sites", created.id));
if (created) openRecord("sites", created.id);
};
return (
+18 -10
View File
@@ -12,12 +12,12 @@ import {
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { useFeeds } from "~/api/feeds.ts";
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { RecordLink } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
@@ -225,12 +225,13 @@ function SiteCardHeader({ site }: { site: SiteListItem }) {
return (
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
<RecordLink
page="sites"
id={site.id}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
</RecordLink>
<a
href={site.baseUrl}
target="_blank"
@@ -289,7 +290,9 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "新建 RSS" : "管理 RSS"}</Link>
<RecordLink page="sites" id={site.id} extra={{ tab: "rss" }}>
{f.loaded && !f.count ? "新建 RSS" : "管理 RSS"}
</RecordLink>
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
@@ -402,9 +405,14 @@ function AccountSiteCard({ site, checks, onDialog }: Props) {
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{cf ? (
cf.takeover ? (
<Link to={`${consolePath("sites", site.id)}?tab=origin`} title="在原站视图里接管会话,完成验证后交还">
<RecordLink
page="sites"
id={site.id}
extra={{ tab: "origin" }}
title="在原站视图里接管会话,完成验证后交还"
>
<StatusBadge tone={cf.tone}>{cf.text}</StatusBadge>
</Link>
</RecordLink>
) : (
<StatusBadge tone={cf.tone}>{cf.text}</StatusBadge>
)
@@ -531,9 +539,9 @@ export function SiteTable({
header: "站点",
cell: (s) => (
<div className="min-w-0">
<Link to={consolePath("sites", s.id)} className="font-medium whitespace-nowrap text-foreground hover:underline">
<RecordLink page="sites" id={s.id} className="font-medium whitespace-nowrap text-foreground hover:underline">
{s.name}
</Link>
</RecordLink>
<p className="truncate text-meta text-muted-foreground">{hostOf(s.baseUrl)}</p>
</div>
),
@@ -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 ? (
<span>
<span className="text-warning">{text}</span>
<Link className="ml-2 text-meta underline" to={`${consolePath("sites", site.id)}?tab=origin`}>
<RecordLink className="ml-2 text-meta underline" page="sites" id={site.id} extra={{ tab: "origin" }}>
去原站视图
</Link>
</RecordLink>
</span>
) : (
text
@@ -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<T>(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<string, string> = { 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<Tab, string> = {
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<string, string | null>) => void;
onClose: () => void;
}) {
const shown = useStickyId(id);
const sites = useSites();
const site = sites.data?.items.find((x) => x.id === shown);
return (
<DetailSheet
open={!!id}
onOpenChange={(o) => !o && onClose()}
width="xl"
title={site?.name ?? "站点"}
description={site ? <SiteBadges s={site} /> : undefined}
actions={site ? <SiteActions s={site} onDeleted={onClose} /> : undefined}
>
{shown ? <SiteWorkbench siteId={shown} params={params} onPatch={onPatch} onClose={onClose} /> : null}
</DetailSheet>
);
}
function SiteWorkbench({
siteId,
params,
onPatch,
onClose,
}: {
siteId: string;
params: URLSearchParams;
onPatch: (extra: Record<string, string | null>) => void;
onClose: () => void;
}) {
const [creatingFeed, setCreatingFeed] = useState(false);
const [feedHealth, setFeedHealth] = useState("");
const [dialog, setDialog] = useState<SiteDialog | null>(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=<id>:打开该 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<LocalFilters>({ age: "" });
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
const [quick, setQuick] = useQuickFilters("site");
const qf = formatQuickFilter(quick);
// 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点
const [liveAt, setLiveAt] = useState<string | null>(null);
const live = liveAt !== null && liveAt === (qf ?? "");
const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null);
const [openId, setOpenId] = useState<string | null>(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<string, string>();
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
if (category && !m.has(category)) m.set(category, category);
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = site;
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 <ErrorState plain error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s) return <EmptyState plain icon={Globe} title="站点不存在或已删除" />;
return (
<>
<Tabs value={tab} onValueChange={(v) => onPatch({ tab: v === "overview" ? null : v, feed: null })}>
<TabsList aria-label="站点分节">
{tabs.map((t) => (
<TabsTrigger key={t} value={t}>
{TAB_LABEL[t]}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="resources">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
actions={
<>
{s && !siteCan(s, "list") ? null : (
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() =>
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("已开始抓取"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
刷新
</Button>
)}
{s && !siteCan(s, "search") ? null : (
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
去站点搜
</Button>
)}
</>
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="上传"
value={local.age}
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
options={AGE}
/>
</FilterBar>
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
{/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */}
<QuickFilterBar
facets={pages[0]?.facets}
value={quick}
onChange={setQuick}
hide={["site", "category"]}
hideClear
/>
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{releases.isPending ? (
<LoadingState rows={6} className="p-4" />
) : releases.error && !releases.data ? (
<ErrorState plain title="读取资源失败" error={releases.error} onRetry={() => void releases.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length || quickHidden ? SearchX : Globe}
title={
all.length
? "已加载的结果里没有符合筛选的资源"
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地资源里没有匹配的资源"
: s && !siteCan(s, "list")
? "还没有资源"
: "还没抓取过"
}
description={
all.length
? "上传时间只筛选已加载的结果。翻页加载后续资源或放宽条件。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
: s && !siteCan(s, "list")
? "RSS 抓到的资源会出现在这里。"
: q
? "点「去站点搜」直接在站点搜索。"
: "点「刷新」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
{quickTotal !== undefined && selectedCount(quick)
? `共 ${quickTotal} 条,已加载 ${all.length} 条`
: `已加载 ${all.length} 条`}
{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
<span className="ml-auto" title={accessSummary(reqStats.data)}>
近 24 小时请求站点 {reqStats.data.last24h} 次
</span>
) : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div>
<ul className="divide-y divide-border/60">
{paged.items?.map((r) => (
<ReleaseRow
key={r.id}
r={r}
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
inventoryLoading={inventory.isLoading}
staleBefore={staleBefore}
selected={openId === r.id}
onOpen={() => setOpenId(r.id)}
/>
))}
</ul>
</>
)}
{all.length > 0 || releases.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
</TabsContent>
<TabsContent value="origin">{s ? <SiteOriginPanel site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="snapshots">{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
<TabsContent value="rss">
{/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */}
<FilterBar
onReset={feedHealth ? () => setFeedHealth("") : undefined}
actions={
<Button size="sm" onClick={() => setCreatingFeed(true)} disabled={!s}>
<Plus />
新建 RSS
</Button>
}
>
<FilterSelect
label="健康"
value={feedHealth}
onChange={setFeedHealth}
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
/>
</FilterBar>
<FeedTable
siteId={siteId}
health={(feedHealth || undefined) as FeedHealth | undefined}
filtered={!!feedHealth}
creating={creatingFeed}
onCreatingChange={setCreatingFeed}
openFeedId={feedParam}
/>
</TabsContent>
<TabsContent value="accounts">
{s ? <SiteAccountsTab site={s} checks={checks} onDialog={setDialog} /> : <LoadingState rows={3} />}
</TabsContent>
</Tabs>
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} onDeleted={onClose} />
<ReleaseDetailSheet
siteId={siteId}
release={selected}
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
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 (
<span className="flex flex-wrap items-center gap-1.5">
<a
href={s.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{hostOf(s.baseUrl)}
</a>
{s.kind !== "pt" ? <StatusBadge>{kindLabel(s.kind)}</StatusBadge> : null}
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">抓取已过期</StatusBadge> : null}
</span>
);
}
/** 抽屉标题下的操作:检测健康、刷新登录、更多、问智能体 */
function SiteActions({ s, onDeleted }: { s: SiteListItem; onDeleted: () => void }) {
const [dialog, setDialog] = useState<SiteDialog | null>(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 ? (
<>
<Button
variant="outline"
size="sm"
disabled={!s.enabled || checks.isBusy(s.id, "health")}
onClick={() => void checks.checkHealth(s)}
>
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
<span className="sr-only sm:not-sr-only">检测健康</span>
</Button>
{login ? (
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
) : null}
</>
) : null}
<SiteMoreMenu site={s} onDialog={setDialog} />
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer({ label: `站点:${s.name}`, siteId: s.id })}>
<Bot />
<span className="sr-only sm:not-sr-only">问智能体</span>
</Button>
<SiteDialogHost dialog={dialog} onClose={() => 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 (
<div className="mb-3 space-y-1.5">
{notes.map((n) => (
<p
key={n.text}
className={
n.tone === "warning"
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
: n.tone === "success"
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
}
>
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 break-words">{n.text}</span>
</p>
))}
</div>
);
}
@@ -1,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({
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu" onClick={(e) => e.stopPropagation()}>
{showDetail ? (
<MenuItem icon={<Grid3x3 />} onClick={run(() => navigate(consolePath("subscriptions", sub.id)))}>
<MenuItem icon={<Grid3x3 />} onClick={run(() => openRecord("subscriptions", sub.id))}>
查看对比
</MenuItem>
) : null}
@@ -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<CalendarEntryState, string> = {
function Entry({ e }: { e: SubscriptionCalendarEntry }) {
return (
<Link
to={consolePath("subscriptions", e.subscriptionId)}
<RecordLink
page="subscriptions"
id={e.subscriptionId}
title={`${e.title} ${e.label} · ${CALENDAR_STATE_LABEL[e.state]}${e.total > 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 }) {
<span className="truncate">{e.title}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{e.label}</span>
<span className="sr-only">{CALENDAR_STATE_LABEL[e.state]}</span>
</Link>
</RecordLink>
);
}
@@ -168,8 +168,9 @@ export function SubscriptionCalendarView({
<ul className="pg-slab divide-y divide-border/60 overflow-hidden rounded-xl">
{unknown.map((u) => (
<li key={`${u.subscriptionId}-${u.seasonNumber ?? ""}`}>
<Link
to={consolePath("subscriptions", u.subscriptionId)}
<RecordLink
page="subscriptions"
id={u.subscriptionId}
className="flex min-w-0 items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-foreground/[0.04]"
>
<span className="truncate">{u.title}</span>
@@ -178,7 +179,7 @@ export function SubscriptionCalendarView({
{u.seasonNumber !== null ? ` · S${String(u.seasonNumber).padStart(2, "0")}` : ""}
{u.episodes.length ? ` · ${u.episodes.length} 集` : ""}
</span>
</Link>
</RecordLink>
</li>
))}
</ul>
@@ -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({
<div className="flex min-w-0 items-start gap-3">
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} className="h-20 w-14" />
<div className="min-w-0 flex-1">
<Link
to={consolePath("subscriptions", x.id)}
<RecordLink
page="subscriptions"
id={x.id}
className="block min-w-0 rounded-md outline-none after:absolute after:inset-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-ring"
>
<TitleLine x={x} />
</Link>
</RecordLink>
<div className="relative mt-1.5 flex flex-wrap gap-1">
<Flags x={x} />
</div>
@@ -223,12 +223,13 @@ export function SubscriptionRow({
) : null}
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} small className="h-10 w-7" />
</div>
<Link
to={consolePath("subscriptions", x.id)}
<RecordLink
page="subscriptions"
id={x.id}
className="min-w-0 outline-none after:absolute after:inset-0 after:content-[''] focus-visible:underline"
>
<TitleLine x={x} />
</Link>
</RecordLink>
<ProgressBlock x={x} className="hidden md:block" />
<div className="relative hidden min-w-0 md:block">
<SlotBadges slots={x.slots} max={3} unbound={!x.mediaEntityId} />
@@ -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"
>
<span>这部作品的同一季已有订阅,不能重复创建。</span>
<Link
to={consolePath("subscriptions", dup)}
<RecordLink
page="subscriptions"
id={dup}
onClick={onClose}
className="inline-flex items-center gap-1 font-medium underline underline-offset-4"
>
打开已有订阅
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</RecordLink>
</div>
) : null}
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
@@ -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<string, string> = {
tmdb: "TMDB",
injected: "订阅里填写",
manual: "手动",
covered_floor: "按已整理",
previous: "沿用上次",
};
export function SubscriptionSheet({ id, onClose }: { id: string | null; onClose: () => void }) {
const shown = useStickyId(id);
return (
<DetailSheet open={!!id} onOpenChange={(o) => !o && onClose()} width="xl" title={<SheetTitleText id={shown} />}>
{shown ? <SubscriptionDetail id={shown} /> : null}
</DetailSheet>
);
}
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<SelectedCell | null>(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 <LoadingState rows={6} />;
if (subQ.isError || !sub) return <ErrorState plain error={subQ.error} onRetry={() => 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 (
<>
<header className="flex flex-wrap items-start gap-4">
<CoverPlaceholder title={title} kind={sub.mediaKind} className="h-24 w-16 max-sm:hidden" />
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0">
<p className="text-caption text-muted-foreground">
{[item?.year, SUB_KIND_LABEL[sub.mediaKind], sLabel, SUB_MODE[sub.mode]].filter(Boolean).join(" · ")}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Tone v={SUB_STATUS[sub.status]} />
{item?.group ? <StatusBadge>{GROUP_LABEL[item.group]}</StatusBadge> : null}
{item?.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
{!sub.mediaEntityId ? <StatusBadge tone="warning">不在媒体库</StatusBadge> : null}
</div>
{!sub.mediaEntityId ? (
<p className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
只按标题「{sub.titleQuery ?? title}
」订阅,还没关联到媒体库作品,不会自动搜索和下载。先把作品加入媒体库,再从作品订阅。
</p>
) : null}
{item ? (
<div className="grid max-w-xl gap-2">
<ProgressBlock x={item} />
<SlotBadges slots={item.slots} max={6} unbound={!sub.mediaEntityId} />
</div>
) : null}
</div>
<div className="flex w-full flex-wrap items-center gap-2">
{tracking && sub.mediaEntityId ? (
<Button variant="outline" size="sm" onClick={() => ops.search(sub.id)} disabled={ops.pending}>
<Search />
立即搜索
</Button>
) : null}
{!ended ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil />
编辑
</Button>
) : null}
{tracking ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "pause")} disabled={ops.pending}>
<Pause />
暂停
</Button>
) : sub.status === "paused" ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resume")} disabled={ops.pending}>
<Play />
恢复
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resubscribe")} disabled={ops.pending}>
<RotateCcw />
重新订阅
</Button>
)}
<SubscriptionMenu sub={sub} onEdit={() => setEditing(true)} showDetail={false} />
</div>
</header>
<div className="space-y-5">
<Panel
title="覆盖对比"
description={cov ? `按收录版本逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按收录版本逐集对比"}
actions={
<Button variant="outline" size="sm" onClick={doRefresh} disabled={refresh.isPending || !sub.mediaEntityId}>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
刷新对比
</Button>
}
>
{!sub.mediaEntityId ? (
<p className="text-caption text-muted-foreground">作品加入媒体库后才能对比,加入后自动关联到这个订阅。</p>
) : covQ.isPending ? (
<LoadingState rows={3} />
) : covQ.isError ? (
<ErrorState plain error={covQ.error} onRetry={() => covQ.refetch()} />
) : !cov || cov.seasons.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。
</p>
) : (
<div className="space-y-4">
{cov.seasons.length > 1 || cov.mediaKind !== "movie" ? (
<div role="tablist" aria-label="季" className="flex gap-1 overflow-x-auto scroll-bleed">
{seasonTabs.map((t, i) => (
<button
key={t.title + i}
type="button"
role="tab"
aria-selected={i === seasonIdx}
onClick={() => setSeason(i)}
className={cn(
"flex shrink-0 cursor-pointer flex-col items-start rounded-lg border px-3 py-1.5 text-left transition-colors duration-micro",
i === seasonIdx ? "pill-selected" : "border-transparent hover:bg-foreground/[0.06]",
)}
>
<span className="text-sm font-medium text-foreground">{t.title}</span>
<span className="text-meta text-muted-foreground tabular-nums">
{t.stat}
{t.source ? ` · ${t.source}` : ""}
{t.locked ? " · 锁定" : ""}
</span>
</button>
))}
</div>
) : null}
<CoverageMatrix cov={cov} seasonIndex={seasonIdx} onSelect={setSel} />
<CoverageLegend />
</div>
)}
</Panel>
{sub.mediaEntityId ? <SlotPanel sub={sub} cov={cov} item={item} canFill={tracking} /> : null}
{sub.mediaEntityId && sub.mediaKind !== "movie" ? (
<SubscriptionMappingSummary entityId={sub.mediaEntityId} seasonUnitId={sub.seasonUnitId} />
) : null}
<div className="grid gap-5 xl:grid-cols-2">
<Panel title="订阅信息">
<KvList
items={[
{ label: "方式", value: SUB_MODE[sub.mode] },
...(sub.mediaKind !== "movie" ? [{ label: "季范围", value: SEASON_SCOPE[sub.seasonScope] }] : []),
{ label: "优先级", value: PRIORITY[sub.priority] },
{ label: "来源", value: SUB_ORIGIN[sub.origin] ?? sub.origin },
{
label: "站点",
value: item
? foldNames(
item.sites.map((s) => 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: (
<Link
className="underline-offset-4 hover:underline"
to={consolePath("library", sub.mediaEntityId)}
>
查看作品
</Link>
),
},
]
: []),
]}
/>
</Panel>
{sub.mediaEntityId ? (
<Panel title="收录版本规则" description="条件、阶梯与升级;新建的收录版本排在最后">
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
</Panel>
) : null}
</div>
</div>
<CellSheet
sel={sel}
subscriptionId={sub.id}
mediaEntityId={sub.mediaEntityId}
canFill={tracking}
onClose={() => setSel(null)}
/>
{editing ? <SubscriptionFormDialog sub={sub} title={title} onClose={() => setEditing(false)} /> : null}
</>
);
}
+4 -3
View File
@@ -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;
@@ -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<AssetOperation["status"], StatusTone> = {
planned: "neutral",
started: "neutral",
succeeded: "success",
failed: "destructive",
skipped: "neutral",
compensated: "warning",
};
const OP_STATUS: Record<AssetOperation["status"], string> = {
planned: "待执行",
started: "进行中",
succeeded: "成功",
failed: "失败",
skipped: "已跳过",
compensated: "已回滚",
};
const OP_PHASE: Record<AssetOperation["phase"], string> = {
decision: "判定",
prepare: "准备",
transfer: "传输",
verify: "校验",
commit: "提交",
cleanup: "清理",
compensate: "回滚",
};
const OP_KIND: Record<AssetOperation["kind"], string> = {
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 <LoadingState rows={3} />;
if (ops.error) return <ErrorState plain error={ops.error} onRetry={() => void ops.refetch()} />;
if (!ops.data.items.length) return <p className="text-caption text-muted-foreground">这个任务没有文件操作。</p>;
return (
<Timeline
items={ops.data.items.map((o) => ({
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 ? (
<RecordLink page="organize" id={o.organizationRecordId} className="underline underline-offset-4">
整理记录 {o.organizationRecordId.slice(0, 8)}
</RecordLink>
) : o.downloadTaskId ? (
<RecordLink page="downloads" id={o.downloadTaskId} className="underline underline-offset-4">
下载任务 {o.downloadTaskId.slice(0, 8)}
</RecordLink>
) : 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 (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
width="lg"
title={t ? (TASK_KIND_LABEL[t.kind] ?? t.kind) : "任务"}
description={t ? <TaskStatusLine t={t} /> : undefined}
footer={t ? <TaskActions t={t} /> : null}
>
{!runMissing ? (
<ErrorState plain error={runError} />
) : task.isPending || (notFound && legacy.isPending) || engineRunId || downloadId ? (
<LoadingState rows={5} />
) : !t ? (
<ErrorState plain error={task.error} onRetry={() => void task.refetch()} />
) : (
<>
<TaskSummary t={t} />
<DetailSection title="文件操作">
<Operations taskId={t.id} />
</DetailSection>
</>
)}
</DetailSheet>
);
}
+5 -38
View File
@@ -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 (
<>
<PageHeader
title={t?.name ?? "下载任务"}
className="[&_h1]:break-all"
actions={
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("downloads")}>
<ArrowLeft />
下载面板
</Link>
</Button>
}
/>
<div className="pg-slab rounded-xl p-4 md:p-5">
{task.isPending ? (
<LoadingState rows={5} />
) : task.error ? (
<ErrorState plain error={task.error} onRetry={() => void task.refetch()} />
) : (
<DownloadDetail task={task.data} />
)}
</div>
</>
);
export default function LegacyRedirect() {
return null;
}
+54 -43
View File
@@ -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=<id>;旧的下载详情页 /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<string | null>(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<DownloadBoardRow>[])),
[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 ? (
<DownloadDetail task={openTask} live={openRow?.kind === "task" ? openRow.live : null} />
) : openPlan ? (
<PlanDetail p={openPlan} />
) : fetched.error && !taskMissing ? (
<ErrorState plain error={fetched.error} onRetry={() => void fetched.refetch()} />
) : (
<LoadingState rows={5} />
),
};
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() {
/>
<DetailSheet
open={!!open}
onOpenChange={(o) => {
if (o) return;
setOpenId(null);
closeDeep();
}}
open={sheetOpen && !endedPlan}
onOpenChange={(o) => !o && sheet.close()}
width="lg"
title={open?.name ?? ""}
footer={
open?.kind === "task" ? (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("downloads", open.id)}>打开详情页</Link>
</Button>
) : null
}
title={shown.title}
>
{open?.kind === "task" ? <DownloadDetail task={open} live={open.live} /> : null}
{open?.kind === "plan" ? <PlanDetail p={open} /> : null}
{shown.body}
</DetailSheet>
<EndedPlanSheet planId={endedPlan} onClose={closeDeep} />
<EndedPlanSheet planId={endedPlan} onClose={sheet.close} />
</>
);
}
+5 -434
View File
@@ -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<Set<string>>(new Set());
const [restoreTo, setRestoreTo] = useState<string[] | null>(null);
const [purgeAll, setPurgeAll] = useState(false);
const [purgeIds, setPurgeIds] = useState<string[] | null>(null);
const [confirmText, setConfirmText] = useState("");
/** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */
const [failedIds, setFailedIds] = useState<Map<string, { code: string; message: string }>>(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<TrashItem>[] = [
{
key: "pick",
header: (
<Checkbox
checked={!!rows?.length && picked.size === rows.length}
partial={picked.size > 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) => (
<Checkbox checked={picked.has(r.id)} label={`选择 ${baseName(r.originalPath)}`} onToggle={() => toggle(r.id)} />
),
},
{
key: "name",
header: "名称",
cell: (r) => {
const fail = failedIds.get(r.id);
return (
<div className="flex min-w-0 max-w-md items-start gap-2">
{r.isDir ? (
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="truncate text-foreground" title={r.trashPath}>
{baseName(r.originalPath)}
</p>
{fail ? (
<p className="text-meta text-destructive">
{fail.code === "exists" || fail.code === "conflict"
? "原位置已被占用"
: fail.code === "too_large"
? "跨卷还原超过大小上限"
: fail.message}
</p>
) : null}
</div>
</div>
);
},
},
{
key: "origin",
header: "原位置",
hideBelow: "md",
cell: (r) => {
const root = rootName.get(r.rootId);
const dir = parentRel(r.originalPath);
return root ? (
<Link
to={`/console/files?root=${encodeURIComponent(r.rootId)}${dir ? `&path=${encodeURIComponent(dir)}` : ""}`}
className="block max-w-xs truncate font-mono text-meta text-muted-foreground hover:text-foreground hover:underline"
title={`${root.hostPath}/${r.originalPath}`}
>
{dir ? `${root.name}/${dir}` : root.name}
</Link>
) : (
<span className="block max-w-xs truncate font-mono text-meta text-muted-foreground">{r.rootId}</span>
);
},
},
{
key: "size",
header: "大小",
hideBelow: "sm",
align: "right",
cell: (r) => <span className="whitespace-nowrap">{formatBytes(r.size, 1)}</span>,
},
{
key: "deleted",
header: "删除",
hideBelow: "md",
cell: (r) => (
<span
className="block max-w-40 text-muted-foreground"
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
>
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
{r.actor ? <span className="block truncate text-meta">{actorText(r.actor)}</span> : null}
</span>
),
},
{
key: "expires",
header: "到期清除",
cell: (r) => (
<span title={formatDateTime(r.expiresAt)}>
<StatusBadge tone={expiryTone(r.expiresAt)}>
{Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)}
</StatusBadge>
</span>
),
},
{
key: "asset",
header: "媒体库记录",
hideBelow: "lg",
cell: (r) => {
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
return (
<span className="block max-w-48 text-meta" title="还原后不会回到媒体库,需要时重新整理">
{snap.mediaEntityId ? (
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
{label}
</Link>
) : (
label
)}
<span className="block text-muted-foreground">已移出媒体库</span>
</span>
);
},
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<div className="flex justify-end gap-1">
{failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? (
<Button size="xs" variant="outline" onClick={() => setRestoreTo([r.id])}>
<RotateCcw />
还原到…
</Button>
) : (
<>
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
<RotateCcw />
还原
</Button>
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
还原到…
</Button>
</>
)}
{isAdmin ? (
<Button
size="xs"
variant="ghost"
aria-label="立即清除"
title="立即清除"
onClick={() => setPurgeIds([r.id])}
>
<Trash2 />
</Button>
) : null}
</div>
),
},
];
return (
<>
<PageHeader
title="回收站"
description="到期后自动清除,到期前可以还原。还原不会恢复媒体库记录,需要时重新整理。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
<Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}>
<ArrowLeft />
文件管理
</Link>
</>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={!picked.size || restore.isPending}
onClick={() => doRestore([...picked])}
>
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
{picked.size ? `还原 ${picked.size} 项` : "还原选中"}
</Button>
{isAdmin ? (
<>
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
<Trash2 />
{picked.size ? `清除 ${picked.size} 项` : "清除选中"}
</Button>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" disabled={!expired.length}>
<Clock />
清除已到期 {expired.length || ""}
</Button>
}
title="清除已到期的项?"
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
confirmLabel="清除"
destructive
onConfirm={() => doPurge(undefined)}
/>
<Button
size="sm"
variant="destructive"
className="ml-auto"
disabled={!rows?.length}
onClick={() => setPurgeAll(true)}
>
<Trash />
清空回收站
</Button>
</>
) : (
<span className="ml-auto text-meta text-muted-foreground">立即清除需要管理员会话。</span>
)}
</div>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => 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
}
/>
<ConfirmDialog
open={!!purgeIds}
onOpenChange={(o) => !o && setPurgeIds(null)}
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
description="清除后不可恢复。"
confirmLabel="清除"
destructive
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
/>
{/* 清空全部:二次确认,需输入“清空” */}
<ConfirmDialog
open={purgeAll}
onOpenChange={(o) => {
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(""));
}}
>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 清空"
aria-label="输入 清空 以确认"
/>
</ConfirmDialog>
{restoreTo ? (
<RestoreToDialog
ids={restoreTo}
onClose={() => 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<FileRef | null>(null);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>还原到…</DialogTitle>
<DialogDescription>{ids.length} 项 · 选一个可写的媒体库目录。同名项已存在时不覆盖。</DialogDescription>
</DialogHeader>
<DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button disabled={!target || pending} onClick={() => target && onPick(target)}>
{pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
还原到这里
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
export default function LegacyRedirect() {
return null;
}
+204 -156
View File
@@ -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 (
<nav aria-label="登记目录" className="flex flex-col items-center gap-1 py-2.5">
@@ -234,11 +239,11 @@ function TreeRail({
type="button"
title={`${r.name}${r.health === "offline" ? " · 离线" : ""}`}
aria-label={r.name}
aria-current={current === r.id ? "location" : undefined}
aria-current={current === r.id && !trashOpen ? "location" : undefined}
onClick={() => onSelect({ root: r.id, path: "" })}
className={cn(
"relative grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
current === r.id && "pill-selected text-foreground",
current === r.id && !trashOpen && "pill-selected text-foreground",
)}
>
<Icon className="h-4 w-4" />
@@ -254,14 +259,19 @@ function TreeRail({
}),
)}
<span aria-hidden className="my-1 h-px w-6 bg-border" />
<Link
to="/console/files/trash"
<button
type="button"
title={`回收站 ${trashCount}`}
aria-label={`回收站 ${trashCount}`}
className="grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
aria-current={trashOpen ? "location" : undefined}
onClick={onTrash}
className={cn(
"grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
trashOpen && "pill-selected text-foreground",
)}
>
<Trash2 className="h-4 w-4" />
</Link>
</button>
</nav>
);
}
@@ -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 ? <LoadingState rows={4} /> : null}
{roots.error ? <ErrorState plain error={roots.error} onRetry={() => void roots.refetch()} /> : null}
{rootList.length ? (
<DirTree roots={rootList} current={root ? { root: root.id, path } : null} onSelect={go} onDropTo={onDropTo} />
<DirTree
roots={rootList}
current={root && !trashView ? { root: root.id, path } : null}
onSelect={go}
onDropTo={onDropTo}
/>
) : null}
{roots.data && !rootList.length ? (
<EmptyState
@@ -600,14 +621,19 @@ export default function FilesPage() {
className="px-2 py-8"
/>
) : null}
<Link
to="/console/files/trash"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-caption text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
<button
type="button"
aria-current={trashView ? "location" : undefined}
onClick={showTrash}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-caption text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
trashView && "pill-selected text-foreground",
)}
>
<Trash2 className="h-4 w-4" />
回收站
{trashCount && trashCount !== "0" ? <span className="ml-auto tabular-nums">{trashCount}</span> : null}
</Link>
</button>
</div>
);
@@ -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}
/>
)}
</div>
@@ -975,157 +1003,177 @@ export default function FilesPage() {
</aside>
) : null}
<section
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
aria-label="目录内容"
>
{/* 工具栏 */}
{mobile ? (
<div className="space-y-2 border-b border-border px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
<FolderTree />
</Button>
{searchOpen ? (
<div className="min-w-0 flex-1">{searchBox}</div>
) : root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="icon-sm"
aria-label={searchOpen ? "收起搜索" : "搜索"}
onClick={() => {
setSearchOpen((v) => !v);
if (!searchOpen) requestAnimationFrame(() => searchRef.current?.focus());
}}
>
{searchOpen ? <X /> : <Search />}
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="更多"
onClick={(ev) => openMenuAt(ev.currentTarget, mobileMore())}
>
<MoreHorizontal />
</Button>
</div>
{chips}
</div>
) : (
<div className="space-y-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label="上一级"
title="上一级(Alt+↑)"
disabled={!path}
onClick={up}
>
<ArrowUp />
</Button>
{root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="icon-sm"
aria-label="刷新"
title="刷新"
onClick={async () => {
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
void roots.refetch();
void list.refetch();
}}
>
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="快捷键"
title="快捷键(?)"
onClick={() => setDialog({ kind: "shortcuts" })}
>
<Keyboard />
</Button>
<span
title={
root?.writable
? undefined
: root?.kind === "download"
? "下载目录里不能新建目录"
: "只读目录,不能新建"
}
>
{trashView ? (
<section
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-y-auto rounded-2xl p-3 md:p-4"
aria-label="回收站"
>
<TrashView
leading={
mobile ? (
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
<FolderTree />
</Button>
) : null
}
/>
</section>
) : (
<section
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
aria-label="目录内容"
>
{/* 工具栏 */}
{mobile ? (
<div className="space-y-2 border-b border-border px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
<FolderTree />
</Button>
{searchOpen ? (
<div className="min-w-0 flex-1">{searchBox}</div>
) : root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="sm"
disabled={!root?.writable}
onClick={() => setDialog({ kind: "mkdir" })}
size="icon-sm"
aria-label={searchOpen ? "收起搜索" : "搜索"}
onClick={() => {
setSearchOpen((v) => !v);
if (!searchOpen) requestAnimationFrame(() => searchRef.current?.focus());
}}
>
<FolderPlus />
新建目录
{searchOpen ? <X /> : <Search />}
</Button>
</span>
{root ? <UploadButton root={root} path={path} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{searchBox}
<Button
variant="outline"
size="icon-sm"
aria-label="更多"
onClick={(ev) => openMenuAt(ev.currentTarget, mobileMore())}
>
<MoreHorizontal />
</Button>
</div>
{chips}
<div className="ml-auto">{sortControls}</div>
</div>
</div>
)}
) : (
<div className="space-y-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label="上一级"
title="上一级(Alt+↑)"
disabled={!path}
onClick={up}
>
<ArrowUp />
</Button>
{root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="icon-sm"
aria-label="刷新"
title="刷新"
onClick={async () => {
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
void roots.refetch();
void list.refetch();
}}
>
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="快捷键"
title="快捷键(?)"
onClick={() => setDialog({ kind: "shortcuts" })}
>
<Keyboard />
</Button>
<span
title={
root?.writable
? undefined
: root?.kind === "download"
? "下载目录里不能新建目录"
: "只读目录,不能新建"
}
>
<Button
variant="outline"
size="sm"
disabled={!root?.writable}
onClick={() => setDialog({ kind: "mkdir" })}
>
<FolderPlus />
新建目录
</Button>
</span>
{root ? <UploadButton root={root} path={path} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{searchBox}
{chips}
<div className="ml-auto">{sortControls}</div>
</div>
</div>
)}
{first?.truncated ? (
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
</div>
) : null}
{first?.truncated ? (
<div
role="status"
className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning"
>
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
</div>
) : null}
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
{/* 状态栏 */}
{root && !offline && root.health !== "offline" && !mobile ? (
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
<span>
{first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"}
</span>
{selCount ? (
{/* 状态栏 */}
{root && !offline && root.health !== "offline" && !mobile ? (
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
<span>
已选 {selCount}
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
{first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"}
</span>
) : null}
{/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */}
{root.usage ? <span>剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null}
</div>
) : null}
{!mobile ? actionBar : null}
</section>
{selCount ? (
<span>
已选 {selCount}
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
</span>
) : null}
{/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */}
{root.usage ? <span>剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null}
</div>
) : null}
{!mobile ? actionBar : null}
</section>
)}
</div>
{mobile ? actionBar : null}
{mobile && !trashView ? actionBar : null}
{/* 手机:登记目录与目录树收进左侧抽屉 */}
<Sheet open={treeSheet} onOpenChange={setTreeSheet}>
@@ -1139,7 +1187,7 @@ export default function FilesPage() {
<ContextMenu anchor={menu?.at ?? null} items={menu?.items ?? []} onClose={() => setMenu(null)} />
{detail && root ? (
{detail && root && !trashView ? (
<FileDetail
root={root}
entry={detail}
+3 -2
View File
@@ -25,6 +25,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { recordPath } from "~/components/console/record-sheet.tsx";
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
@@ -226,7 +227,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
<ul className="divide-y divide-border/60">
{downloads.map((t) => (
<Row key={t.downloadTaskId}>
<Link to={`/console/downloads/${t.downloadTaskId}`} className="min-w-0 flex-1 hover:underline">
<Link to={recordPath("downloads", t.downloadTaskId)} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{t.name}</span>
{t.status === "stalled" ? <span className="block text-meta text-warning">没有进展</span> : null}
</Link>
@@ -288,7 +289,7 @@ function RecentImports({ d }: { d: Dashboard }) {
{rows.map((r) => (
<Row key={r.recordId}>
<Link
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : `/console/organize/${r.recordId}`}
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : recordPath("organize", r.recordId)}
className="min-w-0 flex-1 hover:underline"
>
<span className="block truncate">{r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"}</span>
+13 -9
View File
@@ -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) => (
<div className="min-w-0">
<Link
to={consolePath("downloads", d.downloadTask.id)}
<RecordLink
page="downloads"
id={d.downloadTask.id}
className="block max-w-[14rem] truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={d.downloadTask.name}
>
{d.downloadTask.name}
</Link>
</RecordLink>
<p className="text-meta text-muted-foreground">
{d.plan ? `${PURPOSE_LABEL[d.plan.purpose]} · ` : d.downloadTask.origin === "adopted" ? "接管 · " : ""}
{formatDateTime(d.downloadTask.createdAt)}
@@ -939,14 +941,15 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
<div className="flex flex-wrap items-center gap-1.5">
{d.lastOrganizationStatus ? <Tone v={ORG_STATUS[d.lastOrganizationStatus]} /> : null}
{d.organizationRecordIds.map((rid, i) => (
<Link
<RecordLink
key={rid}
to={consolePath("organize", rid)}
page="organize"
id={rid}
className="inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
>
记录 {i + 1}
<ChevronRight className="h-3 w-3" />
</Link>
</RecordLink>
))}
</div>
) : (
@@ -1018,13 +1021,14 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
{(r) => (
<li key={r.id} className="flex min-w-0 flex-wrap items-center gap-2">
<Tone v={ORG_STATUS[r.status]} />
<Link
to={consolePath("organize", r.id)}
<RecordLink
page="organize"
id={r.id}
className="min-w-0 max-w-full truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={r.sourcePath ?? r.id}
>
{r.targetPath ?? r.sourcePath ?? r.id}
</Link>
</RecordLink>
<span className="text-meta text-muted-foreground tabular-nums">
{r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "智能体" : "自动"} · {r.fileSucceeded}/
{r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
+5 -22
View File
@@ -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<Set<string>>(() => new Set());
const anchor = useRef<string | null>(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({
},
]}
/>
<LogDetailSheet
logId={openId}
entry={openEntry}
onOpenLog={(id) =>
update((p) => {
p.delete("run");
p.set("log", id);
})
}
onClose={() =>
update((p) => {
p.delete("log");
p.delete("run");
})
}
/>
<LogDetailSheet logId={openId} entry={openEntry} onOpenLog={(id) => sheet.open(id)} onClose={sheet.close} />
</>
);
}
+5 -308
View File
@@ -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<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "整理中", tone: "neutral" },
succeeded: { label: "成功", tone: "success" },
failed: { label: "失败", tone: "destructive" },
skipped: { label: "已跳过", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
const ROLE_LABEL = {
main: "主视频",
subtitle: "字幕",
audio: "音轨",
bluray_dir: "原盘目录",
nfo: "NFO",
image: "图片",
other: "其他",
} as const;
// 记录抽屉(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 (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">{label}</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
</p>
</div>
);
}
function duration(a: string | null, b: string | null) {
if (!a || !b) return null;
const s = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
return s < 60 ? `${s.toFixed(1)} 秒` : `${Math.round(s / 60)} 分钟`;
}
function FileRow({ f }: { f: PublicOrganizationFile }) {
const [open, setOpen] = useState(false);
const st = FILE_STATUS[f.status];
const reason = f.failure ?? f.skipReason;
return (
<li className="min-w-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
<span className="shrink-0 text-meta text-muted-foreground">{ROLE_LABEL[f.role]}</span>
<span className="min-w-0 flex-1 truncate font-mono text-meta text-foreground">
{(f.target.path ?? f.source.path ?? "").split("/").pop()}
</span>
{f.sizeBytes !== null ? (
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
{formatBytes(f.sizeBytes, 1)}
</span>
) : null}
</button>
{open ? (
<div className="space-y-2 px-3 pb-3 pl-9">
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={f.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<PathBlock label="整理后" p={f.target} failed={f.status === "failed"} />
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
</p>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(reason) },
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
{ label: "文件结果 ID", value: f.id },
]}
/>
</div>
) : null}
</li>
);
}
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 (
<>
<PageHeader
title="整理记录"
description={
r?.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : r ? "未关联作品" : "加载中…"
}
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to="/console/organize">
<ArrowLeft />
全部记录
</Link>
</Button>
{r && canReorganize(r) ? (
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
<Wand2 />
重新整理
</Button>
) : null}
{r && (r.status === "failed" || r.status === "partial") ? (
<Button
variant="outline"
size="sm"
onClick={() =>
openAgentDrawer({
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
recordId: r.id,
entityId: r.media?.entityId,
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
})
}
>
<Bot />
让智能体排查
</Button>
) : null}
{r && !isRunning(r) ? (
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setRemoving(true)}>
<Trash2 />
删除
</Button>
) : null}
</>
}
/>
{rec.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={5} />
</div>
) : rec.error || !r ? (
<ErrorState error={rec.error} onRetry={() => void rec.refetch()} />
) : (
<div className="space-y-4">
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{r.media ? (
<CoverPlaceholder
small
title={r.media.title}
kind={r.media.kind}
posterPath={r.media.posterPath}
className="mr-1 h-12 w-8 rounded-md"
/>
) : null}
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
<span className="text-meta text-muted-foreground tabular-nums">
{[
`${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(" · ")}
</span>
</div>
{recordFileProgress(r) ? (
<p className="break-all text-caption text-muted-foreground">
已完成 {recordFileProgress(r)?.completed}/{recordFileProgress(r)?.total} 个文件
{recordFileProgress(r)?.current ? ` · 当前文件:${recordFileProgress(r)?.current}` : ""}
</p>
) : null}
{r.reason ? (
<p
className={cn(
"rounded-lg border px-3 py-2 text-caption break-words",
r.status === "failed"
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-warning/40 bg-warning/10 text-warning",
)}
>
{reasonLine(r.reason)}
</p>
) : null}
{r.attention ? (
<div className="space-y-2 rounded-lg border border-warning/40 px-3 py-2">
<p className="text-caption text-foreground">{attentionLine(r) ?? "这条记录需要处理"}</p>
<RecordAttentionActions r={r} onReorganize={() => setReorganize(true)} />
</div>
) : null}
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={r.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<PathBlock label="整理后" p={r.target} failed={r.status === "failed"} />
</div>
<KvList
items={[
{ label: "开始", value: formatDateTime(r.startedAt) },
{ label: "完成", value: formatDateTime(r.finishedAt) },
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
...(r.download
? [
{
label: "下载任务",
value: `${r.download.name}${r.download.downloaderName ? ` · ${r.download.downloaderName}` : ""}`,
},
]
: []),
]}
/>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(r.reason) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
{
label: "外部编号",
value: r.media?.externalIds.map((e) => `${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 ? (
<Link
to={`/console/library/${r.media.entityId}`}
className="inline-block text-caption underline underline-offset-4"
>
查看作品
</Link>
) : null}
</section>
<section className="pg-slab overflow-hidden rounded-xl">
<h2 className="px-4 pt-4 pb-2 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
文件明细({r.files?.length ?? 0})
</h2>
{r.files?.length ? (
<PaginatedList
rows={r.files}
label="整理文件明细"
resetKey={r.id}
as="ul"
className="divide-y divide-border/60 border-t border-border/60"
>
{(f) => <FileRow key={f.id} f={f} />}
</PaginatedList>
) : (
<p className="px-4 pb-4 text-caption text-muted-foreground">
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
</p>
)}
</section>
<OrganizeScrapeHistory recordId={r.id} />
</div>
)}
{r ? (
<DeleteRecordsDialog
records={[r]}
open={removing}
onOpenChange={setRemoving}
onDone={(removed) => {
if (removed.includes(r.id)) navigate("/console/organize");
}}
/>
) : null}
{reorganize && r ? (
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
) : null}
</>
);
export default function LegacyRedirect() {
return null;
}
+8 -5
View File
@@ -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) => (
<RowMenu
r={r}
onOpen={() => 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}
<OrganizeRecordSheet id={sheet.id} onClose={sheet.close} />
<DeleteRecordsDialog
records={toDelete ?? []}
open={!!toDelete}
+37 -5
View File
@@ -4,7 +4,9 @@ import { BUSINESS_OBJECT_TYPE, RUN_STATUS_LEVEL } from "~/components/logs/labels
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
// 界面第二轮(V 线):任务、审计、引擎面板、计划合进日志页 /console/logs;任务详情 /console/tasks/:id 保留为独立页。
// 界面第二轮(V 线):任务、审计、引擎面板、计划合进日志页 /console/logs。
// 记录抽屉(§9,2026-10-08):列表里打开的记录不再有独立页,/console/<列表>/:id → /console/<列表>?open=:id;
// 任务详情 /console/tasks/:id → 日志页任务抽屉;回收站 /console/files/trash → 文件管理里的回收站视图。
const IDENTITY_TAB: Record<string, string> = {
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:命中旧链接就跳转 */
+21 -8
View File
@@ -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=<id>(「自动收新作品」的策略版本也用 ?open=,各认自己的 id);新建只在本页状态里
const sheet = useRecordSheet();
const [creating, setCreating] = useState(false);
const lastId = useRef<string | null>(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() {
</Tabs>
<RuleGroupEditor
open={editor.open}
open={creating || (!!sheet.id && !!editing)}
group={editing}
defaultKind={(kind || "acquisition") as RuleGroupKind}
onOpenChange={(o) => setEditor((e) => ({ ...e, open: o }))}
onOpenChange={(o) => {
if (o) return;
if (creating) setCreating(false);
else sheet.close();
}}
/>
</>
);
+8 -42
View File
@@ -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() {
<>
<PageHeader title="设置" description="密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:搜索 + 分区下拉 + 节横排 */}
{/* 窄屏:搜索 + 分区下拉。左栏只有一层(console-ia.md §9):节是页面里的小标题,搜索与 #分区/节 深链仍能定位 */}
<div className="grid gap-3 lg:hidden">
<SettingsSearch />
<Select value={section.id} onValueChange={(id) => navigate({ hash: settingsHash(id) })}>
@@ -113,30 +117,9 @@ export default function SettingsPage() {
</SelectContent>
</Select>
<p className="text-caption text-muted-foreground">{section.description}</p>
{section.anchors.length > 1 ? (
<nav aria-label="本分区目录" className="scroll-bleed -mx-1 overflow-x-auto px-1 pb-1">
<ul className="flex w-max gap-1.5">
{section.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(section.id, a.id) }}
className={cn(
"block rounded-full border px-3 py-1 text-caption whitespace-nowrap transition-colors duration-micro",
anchor === a.id
? "pill-selected text-foreground"
: "border-border/70 text-muted-foreground hover:text-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
</nav>
) : null}
</div>
{/* 宽屏:搜索 + 左侧分组导航 */}
{/* 宽屏:搜索 + 左侧分组导航(只列分区,不展开节) */}
<div className="hidden lg:block">
<div className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-3 overflow-y-auto pb-2">
<SettingsSearch />
@@ -164,23 +147,6 @@ export default function SettingsPage() {
<s.icon className="size-4 shrink-0" aria-hidden />
{s.label}
</Link>
{active && s.anchors.length > 1 ? (
<ul className="mt-0.5 mb-1.5 ml-[1.375rem] grid gap-0.5 border-l border-border/60 pl-3">
{s.anchors.map((a) => (
<li key={a.id}>
<Link
to={{ hash: settingsHash(s.id, a.id) }}
className={cn(
"block rounded-md px-2 py-1 text-caption transition-colors duration-micro hover:text-foreground",
anchor === a.id ? "text-foreground" : "text-muted-foreground",
)}
>
{a.label}
</Link>
</li>
))}
</ul>
) : null}
</li>
);
})}
+5 -558
View File
@@ -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<T>(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<string, string> = { 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<Tab, string> = {
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<SiteDialog | null>(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=<id>:打开该 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<LocalFilters>({ age: "" });
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
const [quick, setQuick] = useQuickFilters("site");
const qf = formatQuickFilter(quick);
// 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点
const [liveAt, setLiveAt] = useState<string | null>(null);
const live = liveAt !== null && liveAt === (qf ?? "");
const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null);
const [openId, setOpenId] = useState<string | null>(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<string, string>();
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
if (category && !m.has(category)) m.set(category, category);
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = site;
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 <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
return (
<EmptyState
icon={Globe}
title="站点不存在或已删除"
action={
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}>
<ArrowLeft />
返回站点
</Link>
</Button>
}
/>
);
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 (
<>
<PageHeader
title={s?.name ?? "站点"}
description={
s ? (
<span className="flex flex-wrap items-center gap-1.5">
<a
href={s.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{hostOf(s.baseUrl)}
</a>
{s.kind !== "pt" ? <StatusBadge>{kindLabel(s.kind)}</StatusBadge> : null}
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">抓取已过期</StatusBadge> : null}
</span>
) : (
"加载中…"
)
}
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("sites")}>
<ArrowLeft />
站点
</Link>
</Button>
{s && checkable ? (
<>
<Button
variant="outline"
size="sm"
disabled={!s.enabled || checks.isBusy(s.id, "health")}
onClick={() => void checks.checkHealth(s)}
>
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
<span className="sr-only sm:not-sr-only">检测健康</span>
</Button>
{login ? (
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
) : null}
</>
) : null}
{s ? <SiteMoreMenu site={s} onDialog={setDialog} /> : null}
<Button
variant="ghost"
size="sm"
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
>
<Bot />
<span className="sr-only sm:not-sr-only">问智能体</span>
</Button>
</>
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "overview") p.delete("tab");
else p.set("tab", v);
p.delete("feed");
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="站点分节">
{tabs.map((t) => (
<TabsTrigger key={t} value={t}>
{TAB_LABEL[t]}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="resources">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
actions={
<>
{s && !siteCan(s, "list") ? null : (
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() =>
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("已开始抓取"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
刷新
</Button>
)}
{s && !siteCan(s, "search") ? null : (
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
去站点搜
</Button>
)}
</>
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="上传"
value={local.age}
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
options={AGE}
/>
</FilterBar>
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
{/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */}
<QuickFilterBar
facets={pages[0]?.facets}
value={quick}
onChange={setQuick}
hide={["site", "category"]}
hideClear
/>
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{releases.isPending ? (
<LoadingState rows={6} className="p-4" />
) : releases.error && !releases.data ? (
<ErrorState plain title="读取资源失败" error={releases.error} onRetry={() => void releases.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length || quickHidden ? SearchX : Globe}
title={
all.length
? "已加载的结果里没有符合筛选的资源"
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地资源里没有匹配的资源"
: s && !siteCan(s, "list")
? "还没有资源"
: "还没抓取过"
}
description={
all.length
? "上传时间只筛选已加载的结果。翻页加载后续资源或放宽条件。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
: s && !siteCan(s, "list")
? "RSS 抓到的资源会出现在这里。"
: q
? "点「去站点搜」直接在站点搜索。"
: "点「刷新」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
{quickTotal !== undefined && selectedCount(quick)
? `共 ${quickTotal} 条,已加载 ${all.length} 条`
: `已加载 ${all.length} 条`}
{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
<span className="ml-auto" title={accessSummary(reqStats.data)}>
近 24 小时请求站点 {reqStats.data.last24h} 次
</span>
) : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div>
<ul className="divide-y divide-border/60">
{paged.items?.map((r) => (
<ReleaseRow
key={r.id}
r={r}
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
inventoryLoading={inventory.isLoading}
staleBefore={staleBefore}
selected={openId === r.id}
onOpen={() => setOpenId(r.id)}
/>
))}
</ul>
</>
)}
{all.length > 0 || releases.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
</TabsContent>
<TabsContent value="origin">{s ? <SiteOriginPanel site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="snapshots">{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
<TabsContent value="rss">
{/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */}
<FilterBar
onReset={feedHealth ? () => setFeedHealth("") : undefined}
actions={
<Button size="sm" onClick={() => setCreatingFeed(true)} disabled={!s}>
<Plus />
新建 RSS
</Button>
}
>
<FilterSelect
label="健康"
value={feedHealth}
onChange={setFeedHealth}
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
/>
</FilterBar>
<FeedTable
siteId={siteId}
health={(feedHealth || undefined) as FeedHealth | undefined}
filtered={!!feedHealth}
creating={creatingFeed}
onCreatingChange={setCreatingFeed}
openFeedId={feedParam}
/>
</TabsContent>
<TabsContent value="accounts">
{s ? <SiteAccountsTab site={s} checks={checks} onDialog={setDialog} /> : <LoadingState rows={3} />}
</TabsContent>
</Tabs>
<SiteDialogHost
dialog={dialog}
onClose={() => setDialog(null)}
onDeleted={() => navigate(consolePath("sites"))}
/>
<ReleaseDetailSheet
siteId={siteId}
release={selected}
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
onClose={() => setOpenId(null)}
/>
</>
);
}
function CoverageBanner({
coverage,
refreshError,
}: {
coverage: import("@umt/contracts").WorkbenchCoverage | undefined;
refreshError: unknown;
}) {
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` });
if (coverage) {
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" });
if (coverage.stale)
notes.push({
tone: "warning",
text: coverage.lastSnapshotAt
? `抓取已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "还没抓取过,列表只来自 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 (
<div className="mb-3 space-y-1.5">
{notes.map((n) => (
<p
key={n.text}
className={
n.tone === "warning"
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
: n.tone === "success"
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
}
>
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 break-words">{n.text}</span>
</p>
))}
</div>
);
export default function LegacyRedirect() {
return null;
}
+6
View File
@@ -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<SiteKind | "any" | null>(null);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
@@ -221,6 +226,7 @@ export default function SitesPage() {
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
<SiteSheet id={sheet.id} params={params} onPatch={sheet.patch} onClose={sheet.close} />
</>
);
}
+3 -2
View File
@@ -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=<id> → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。
// ?siteId= → 该站点抽屉的 RSS Tab;?feed=<id> → 查 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"));
@@ -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<string, string> = {
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<SelectedCell | null>(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 <LoadingState rows={6} />;
if (subQ.isError || !sub) return <ErrorState error={subQ.error} onRetry={() => 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 (
<>
<Link
to={consolePath("subscriptions")}
className="mb-3 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
订阅
</Link>
<header className="mb-5 flex flex-wrap items-start gap-4">
<CoverPlaceholder title={title} kind={sub.mediaKind} className="h-24 w-16 max-sm:hidden" />
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground">{title}</h1>
<p className="text-caption text-muted-foreground">
{[item?.year, SUB_KIND_LABEL[sub.mediaKind], sLabel, SUB_MODE[sub.mode]].filter(Boolean).join(" · ")}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Tone v={SUB_STATUS[sub.status]} />
{item?.group ? <StatusBadge>{GROUP_LABEL[item.group]}</StatusBadge> : null}
{item?.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
{!sub.mediaEntityId ? <StatusBadge tone="warning">不在媒体库</StatusBadge> : null}
</div>
{!sub.mediaEntityId ? (
<p className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
只按标题「{sub.titleQuery ?? title}
」订阅,还没关联到媒体库作品,不会自动搜索和下载。先把作品加入媒体库,再从作品订阅。
</p>
) : null}
{item ? (
<div className="grid max-w-xl gap-2">
<ProgressBlock x={item} />
<SlotBadges slots={item.slots} max={6} unbound={!sub.mediaEntityId} />
</div>
) : null}
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
{tracking && sub.mediaEntityId ? (
<Button variant="outline" size="sm" onClick={() => ops.search(sub.id)} disabled={ops.pending}>
<Search />
立即搜索
</Button>
) : null}
{!ended ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil />
编辑
</Button>
) : null}
{tracking ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "pause")} disabled={ops.pending}>
<Pause />
暂停
</Button>
) : sub.status === "paused" ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resume")} disabled={ops.pending}>
<Play />
恢复
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resubscribe")} disabled={ops.pending}>
<RotateCcw />
重新订阅
</Button>
)}
<SubscriptionMenu sub={sub} onEdit={() => setEditing(true)} showDetail={false} />
</div>
</header>
<div className="space-y-5">
<Panel
title="覆盖对比"
description={cov ? `按收录版本逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按收录版本逐集对比"}
actions={
<Button variant="outline" size="sm" onClick={doRefresh} disabled={refresh.isPending || !sub.mediaEntityId}>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
刷新对比
</Button>
}
>
{!sub.mediaEntityId ? (
<p className="text-caption text-muted-foreground">作品加入媒体库后才能对比,加入后自动关联到这个订阅。</p>
) : covQ.isPending ? (
<LoadingState rows={3} />
) : covQ.isError ? (
<ErrorState plain error={covQ.error} onRetry={() => covQ.refetch()} />
) : !cov || cov.seasons.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。
</p>
) : (
<div className="space-y-4">
{cov.seasons.length > 1 || cov.mediaKind !== "movie" ? (
<div role="tablist" aria-label="季" className="flex gap-1 overflow-x-auto scroll-bleed">
{seasonTabs.map((t, i) => (
<button
key={t.title + i}
type="button"
role="tab"
aria-selected={i === seasonIdx}
onClick={() => setSeason(i)}
className={cn(
"flex shrink-0 cursor-pointer flex-col items-start rounded-lg border px-3 py-1.5 text-left transition-colors duration-micro",
i === seasonIdx ? "pill-selected" : "border-transparent hover:bg-foreground/[0.06]",
)}
>
<span className="text-sm font-medium text-foreground">{t.title}</span>
<span className="text-meta text-muted-foreground tabular-nums">
{t.stat}
{t.source ? ` · ${t.source}` : ""}
{t.locked ? " · 锁定" : ""}
</span>
</button>
))}
</div>
) : null}
<CoverageMatrix cov={cov} seasonIndex={seasonIdx} onSelect={setSel} />
<CoverageLegend />
</div>
)}
</Panel>
{sub.mediaEntityId ? <SlotPanel sub={sub} cov={cov} item={item} canFill={tracking} /> : null}
{sub.mediaEntityId && sub.mediaKind !== "movie" ? (
<SubscriptionMappingSummary entityId={sub.mediaEntityId} seasonUnitId={sub.seasonUnitId} />
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<Panel title="订阅信息">
<KvList
items={[
{ label: "方式", value: SUB_MODE[sub.mode] },
...(sub.mediaKind !== "movie" ? [{ label: "季范围", value: SEASON_SCOPE[sub.seasonScope] }] : []),
{ label: "优先级", value: PRIORITY[sub.priority] },
{ label: "来源", value: SUB_ORIGIN[sub.origin] ?? sub.origin },
{
label: "站点",
value: item
? foldNames(
item.sites.map((s) => 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: (
<Link
className="underline-offset-4 hover:underline"
to={consolePath("library", sub.mediaEntityId)}
>
查看作品
</Link>
),
},
]
: []),
]}
/>
</Panel>
{sub.mediaEntityId ? (
<Panel title="收录版本规则" description="条件、阶梯与升级;新建的收录版本排在最后">
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
</Panel>
) : null}
</div>
</div>
<CellSheet
sel={sel}
subscriptionId={sub.id}
mediaEntityId={sub.mediaEntityId}
canFill={tracking}
onClose={() => setSel(null)}
/>
{editing ? <SubscriptionFormDialog sub={sub} title={title} onClose={() => setEditing(false)} /> : null}
</>
);
export default function LegacyRedirect() {
return null;
}
@@ -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<T extends string>({
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}
/>
<SubscriptionSheet id={sheet.id} onClose={sheet.close} />
</>
);
}
+6 -209
View File
@@ -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<AssetOperation["status"], StatusTone> = {
planned: "neutral",
started: "neutral",
succeeded: "success",
failed: "destructive",
skipped: "neutral",
compensated: "warning",
};
const OP_STATUS: Record<AssetOperation["status"], string> = {
planned: "待执行",
started: "进行中",
succeeded: "成功",
failed: "失败",
skipped: "已跳过",
compensated: "已回滚",
};
const OP_PHASE: Record<AssetOperation["phase"], string> = {
decision: "判定",
prepare: "准备",
transfer: "传输",
verify: "校验",
commit: "提交",
cleanup: "清理",
compensate: "回滚",
};
const OP_KIND: Record<AssetOperation["kind"], string> = {
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 <LoadingState rows={3} />;
if (ops.error) return <ErrorState plain error={ops.error} onRetry={() => void ops.refetch()} />;
if (!ops.data.items.length) return <p className="text-caption text-muted-foreground">这个任务没有文件操作。</p>;
return (
<Timeline
items={ops.data.items.map((o) => ({
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 ? (
<Link to={consolePath("organize", o.organizationRecordId)} className="underline underline-offset-4">
整理记录 {o.organizationRecordId.slice(0, 8)}
</Link>
) : o.downloadTaskId ? (
<Link to={consolePath("downloads", o.downloadTaskId)} className="underline underline-offset-4">
下载任务 {o.downloadTaskId.slice(0, 8)}
</Link>
) : undefined,
}))}
/>
);
}
const back = (
<Button asChild variant="ghost" size="sm">
<Link to={`${consolePath("logs")}?type=task`}>
<ArrowLeft />
日志
</Link>
</Button>
);
/**
* /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 (
<>
<PageHeader
title={runTitle(r)}
description={<TaskStatusLine r={r} />}
actions={
<>
{back}
<RunActions r={r} onOpenRun={open} />
</>
}
/>
<div className="pg-slab space-y-5 rounded-xl p-4 md:p-5">
<TaskDetailBody r={r} onOpenRun={open} />
</div>
</>
);
}
if (run.error && !runMissing)
return (
<>
<PageHeader title="任务" actions={back} />
<ErrorState error={run.error} onRetry={() => void run.refetch()} />
</>
);
if (task.data?.engineRunId)
return <Navigate to={`/console/tasks/${encodeURIComponent(task.data.engineRunId)}`} replace />;
if (notFound && legacy.data) return <Navigate to={consolePath("downloads", id)} replace />;
const t = task.data;
return (
<>
<PageHeader
title={t ? (TASK_KIND_LABEL[t.kind] ?? t.kind) : "任务"}
description={t ? <TaskStatusLine2 t={t} /> : undefined}
actions={
<>
{back}
{t ? <TaskActions t={t} /> : null}
</>
}
/>
{run.isPending || task.isPending || (notFound && legacy.isPending) ? (
<div className="pg-slab rounded-xl p-4 md:p-5">
<LoadingState rows={5} />
</div>
) : !t ? (
<ErrorState error={task.error} onRetry={() => void task.refetch()} />
) : (
<div className="space-y-5">
<div className="pg-slab rounded-xl p-4 md:p-5">
<TaskSummary t={t} />
</div>
<div className="pg-slab rounded-xl p-4 md:p-5">
<DetailSection title="文件操作">
<Operations taskId={t.id} />
</DetailSection>
</div>
</div>
)}
</>
);
export default function LegacyRedirect() {
return null;
}
+32 -15
View File
@@ -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,
});