merge: ia-sheets(两级导航,列表记录改右侧抽屉 ?open=)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # CHANGELOG.md # docs/design/console-ia.md
This commit is contained in:
commit
78a4d562f0
60 files changed
+2764
-2461
No files matched your search
@@ -17,6 +17,12 @@
|
||||
- 智能体的回答不再夹带内部字段:模型被要求转述子会话结论、不照抄 ID 和原始字段;旧会话里「childRunId:…」「decisionId: null」这类行和「返回字段」表格从正文里拿掉,原文放进「技术信息」。
|
||||
- 新对话的开场建议改成按所在页面给两条完整的问法,点一下直接发出(例如在整理记录页是「整理记录里没识别出的作品是什么」);不再填半句话。
|
||||
- 输入框只保留 `/先给方案` 一个指令;原来的 `/重跑` 改成会话结束后顶部的「重新运行」按钮。
|
||||
- 从列表打开的记录不再跳到单独的页面,改在右侧抽屉里展开,列表的位置和筛选都不动:整理记录、订阅、站点(含概览、资源、原站视图、抓取、RSS、账号页签)、下载、日志里的任务,以及规则组、媒体库的未识别资源与外部身份、设置里的下载器等实例。
|
||||
- 打开的记录写在地址里,刷新、分享链接都能回到这条;浏览器后退就是关掉抽屉。
|
||||
- 抽屉里再看详情(站点的资源和 RSS、订阅对比的格子)在同一个抽屉里切换,顶部「返回」回到上一层,不再叠第二个抽屉。
|
||||
- 回收站并进文件管理:左边目录树点「回收站」,右侧换成回收站列表。
|
||||
- 设置页左栏只列分区,不再展开节;节是页面里的小标题,搜索和带节的链接照样能定位。
|
||||
- 旧的详情页地址(整理记录、订阅、站点、下载、任务、回收站)继续可用,会打开对应列表页上的抽屉。
|
||||
|
||||
### 注意
|
||||
- 会话存储格式变了:会话文件从 `agent-sessions/<会话 ID>.jsonl` 改为 `agent-sessions/<会话 ID>/` 目录。升级前已经结束的会话照常查看;升级时还在运行或挂起等待(审批、选方案、等子会话)的旧会话不能继续,会以「这个会话由旧版本创建……」失败,需要的话重新发起。
|
||||
|
||||
@@ -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", "下载器"]),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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:命中旧链接就跳转 */
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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]}`);
|
||||
}
|
||||
@@ -418,7 +435,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,
|
||||
});
|
||||
|
||||
@@ -178,10 +178,10 @@
|
||||
|
||||
| 类型 | 规范 | 现有组件 |
|
||||
| --- | --- | --- |
|
||||
| 列表页 | `PageHeader`(标题 + 一句描述 + 主操作)→ `FilterBar`(搜索、状态、对象筛选,状态写入 URL query)→ `DataTable`(行点击开 `DetailSheet`,不跳页)→ 分页 | `page-header` `filter-bar` `data-table` `detail-sheet` 均已有 |
|
||||
| 列表页 | `PageHeader`(标题 + 一句描述 + 主操作)→ `FilterBar`(搜索、状态、对象筛选,状态写入 URL query)→ `DataTable`(行点击开 `DetailSheet`,不跳页,见 §9)→ 分页 | `page-header` `filter-bar` `data-table` `detail-sheet` 均已有 |
|
||||
| 详情页 | 统一头部:标题 + `StatusBadge` + 主操作 1 个 + 次操作收进"更多";下方 `DetailSection` 分节,节顺序:概况 → 进度/状态 → 关联对象 → 历史/证据 | `status-badge` `kv-list` `timeline` |
|
||||
| 跨页关联 | 任何对象名都可点,点开同一个 `DetailSheet` 或深链到其"家"页面 | — |
|
||||
| 设置 | **左侧分区导航(二级路由)+ 分区内锚点 + 分节表单**,每节独立保存 | 复用 `SettingsCard` / `resource-section` |
|
||||
| 设置 | **左侧分区导航 + 分区内锚点 + 分节表单**,每节独立保存;左栏只有分区一层(§9.1) | 复用 `SettingsCard` / `resource-section` |
|
||||
|
||||
设置用"左侧分区 + 锚点"而不是 MP 的顶部 Tab,理由:
|
||||
1. 9 个分区在顶部 Tab 会横向溢出(MP 靠 `show-arrows`,现有 9 Tab 在窄屏已挤);左栏能放每区一句描述,等同 MP 的 `settingTabs.*.description`。
|
||||
@@ -384,5 +384,61 @@ W10 已补(2026-09-28):
|
||||
|
||||
- 侧栏 4 组 13 项:概览(首页、智能体会话)/ 获取 / 整理 / 系统(日志、运行状态、设置)。任务、审计、引擎面板、计划不再有入口。
|
||||
- 智能体入口(2026-10-08):抽屉只有一个按钮入口——桌面是侧栏最上方的「智能体」(Ctrl/⌘+J),没有侧栏的窄屏是顶栏胶囊里的智能体按钮;没有右下角悬浮按钮,也没有最小化小窗。「智能体会话」页 `/console/agent(/:runId)` 用来浏览全部会话,右栏和抽屉是同一个对话组件。页面里的「问智能体」按钮带着当前对象打开同一个抽屉,不算另一个入口。会话列表只有一份(会话页左栏与抽屉的最近会话切换共用):对话与巡检混排、只列顶层会话(`GET /agent/runs?roots=true`),子会话在父会话的会话流里看;旧链接的 `?tab=` 忽略。
|
||||
- 日志页 `/console/logs`:任务、定时与自动、登录、审计合成一份列表,可实时追加、勾选后问智能体;第二个页签「定时任务」是原计划页。旧地址:`/console/tasks`(含 `?tab=` `?status=` `?kind=&ref=` `?run=`)、`/console/engine(/:id)`、`/console/audit`(`?targetType=&targetId=` → 对象筛选)、`/console/schedules(?job=)` 都跳到日志页对应筛选;`/console/tasks/:id` 独立页保留,侧栏高亮「日志」。
|
||||
- 日志页 `/console/logs`:任务、定时与自动、登录、审计合成一份列表,可实时追加、勾选后问智能体;第二个页签「定时任务」是原计划页。旧地址:`/console/tasks`(含 `?tab=` `?status=` `?kind=&ref=` `?run=`)、`/console/engine(/:id)`、`/console/audit`(`?targetType=&targetId=` → 对象筛选)、`/console/schedules(?job=)` 都跳到日志页对应筛选;`/console/tasks/:id` 起初保留为独立页,2026-10-08 起改成日志页的任务抽屉(§9)。
|
||||
- 运行状态页加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势),首页「存储情况」卡换成运行状态简版卡。细节见 `docs/runbook/logs.md`。
|
||||
|
||||
## 9. 导航层级与记录抽屉(2026-10-08,现行标准)
|
||||
|
||||
控制台怎么分层、从列表打开一条记录怎么展示,以这一节为准;[控件标准](ui-controls.md)只引用这里,不另写一份。
|
||||
|
||||
### 9.1 最多两层
|
||||
|
||||
- 侧栏一项 = 一个页面。页面里最多再有**一层**切换(页签或分段控件),不再往下嵌第三层。
|
||||
- 筛选、视图(卡片 / 列表 / 日历)、图表的时间范围是同一页的显示方式,不算一层导航。
|
||||
- 设置页左栏只有一层:分区。节不出现在左栏和窄屏的下拉下面,是页面里的小标题;搜索与 `#分区/节` 深链仍能滚到节和字段。
|
||||
|
||||
### 9.2 从列表打开记录 = 右侧抽屉
|
||||
|
||||
- 列表 / 卡片里点一条(看详情、编辑、排查)打开右侧抽屉 `DetailSheet`(`components/console/detail-sheet.tsx`),列表留在原处,滚动位置和筛选都不动。抽屉材质是 `pg-float`,手机上全宽。
|
||||
- 宽度:`md`(约 32rem,简单记录)、`lg`(约 44rem,有时间线或文件明细)、`xl`(约 64rem,内容重的:订阅对比、站点)。
|
||||
- 打开的记录写在地址栏 `?open=<id>`,其余参数照留;刷新、分享链接、前进后退都能还原。抽屉里的页签等只属于抽屉的参数(站点的 `?tab=`)随抽屉走,关掉时一起去掉。
|
||||
- 在本页打开是 push,所以浏览器后退就是关抽屉;关闭按钮 / Esc 在本页打开时等于后退一步,从别处链接直接进来时 replace 去掉参数、留在列表。抽屉开着时换一条记录是 replace。
|
||||
- 不叠第二层抽屉。抽屉里再打开的详情(站点里的资源、RSS,订阅里的格子)在同一个抽屉里换成子视图,顶部「返回」(Esc 也是先返回);需要更多空间就链到对象自己的页面。
|
||||
- 动作表单(新建、从「⋯」菜单打开的编辑表单、确认)是对话框性质,可以不进地址栏。
|
||||
- 实现:列表页用 `useRecordSheet()`,链接用 `<RecordLink page id>`,代码里跳转用 `useOpenRecord()`,别处拼地址用 `recordPath(page, id)`(都在 `components/console/record-sheet.tsx`)。日志页的记录参数是 `?log=<日志 id>`,任务也可以只带 `?run=<引擎运行 id>`(`taskPath(id)`),规则同上。
|
||||
|
||||
### 9.3 独立页面只给要待一阵子的工作对象
|
||||
|
||||
- 作品:`/console/library/:id`、`/console/media/tmdb/:type/:id`。
|
||||
- 智能体会话:`/console/agent/:runId`。
|
||||
- 其余对象(整理记录、订阅、站点、下载、任务、规则组、外部身份、未识别资源、设置里的实例)都是所属列表页上的抽屉。
|
||||
|
||||
### 9.4 旧地址
|
||||
|
||||
`routes/console/redirects.ts` 把旧的详情页地址换到列表页抽屉,其余参数跟在后面:
|
||||
|
||||
| 旧 | 新 |
|
||||
| --- | --- |
|
||||
| `/console/organize/:id` | `/console/organize?open=:id` |
|
||||
| `/console/subscriptions/:id` | `/console/subscriptions?open=:id` |
|
||||
| `/console/sites/:id(?tab=…)` | `/console/sites?open=:id(&tab=…)` |
|
||||
| `/console/downloads/:id` | `/console/downloads?open=:id`(`/downloads/plans(/:id)` 仍按 §7 跳 `?plan=`) |
|
||||
| `/console/tasks/:id` | `/console/logs?type=task&run=:id`;id 是旧业务任务时抽屉里换成它的引擎运行或显示旧任务概况,是下载任务时换到 `/console/downloads?open=:id` |
|
||||
| `/console/files/trash` | `/console/files?view=trash`(回收站是文件管理右侧的一个视图,左栏「回收站」切过去) |
|
||||
| `/console/sources?siteId=x` | `/console/sites?open=x&tab=rss` |
|
||||
|
||||
站内链接、铃铛、命令面板、任务时间线、整理队列都直接拼新地址;智能体会话里的链接与服务端 `agent/challenge.ts` 生成的地址还是旧格式,靠上表跳转。
|
||||
|
||||
### 9.5 现状对照
|
||||
|
||||
| 页面 | 处理 |
|
||||
| --- | --- |
|
||||
| 整理记录 | 行点开 `lg` 抽屉:状态、操作、路径、文件明细、整理后刮削;队列面板里的「整理记录」换成记录抽屉 |
|
||||
| 订阅 | 卡片 / 列表 / 日历点开 `xl` 抽屉,覆盖对比的格子在同一抽屉里换成子视图 |
|
||||
| 站点 | 卡片 / 列表点开 `xl` 抽屉,里面一层页签(概览、资源、原站视图、抓取、RSS、账号);资源、RSS 详情是子视图 |
|
||||
| 下载面板 | 行点开 `lg` 抽屉,`?open=` 是下载任务或计划 id;不在当前分段里的也能打开 |
|
||||
| 日志 | 行点开抽屉(`?log=` / `?run=`),不再有「打开任务页」 |
|
||||
| 文件管理 | 文件详情原本就是 `?sel=`;回收站并进右侧视图 |
|
||||
| 规则 | 规则组编辑、「自动收新作品」的策略版本查看走 `?open=` |
|
||||
| 媒体库 | 「未识别」「外部身份」的行抽屉走 `?open=`;作品仍是独立页 |
|
||||
| 设置 | 左栏只列分区;各分区里实例列表(下载器、媒体服务器、媒体库目录等)的编辑抽屉走 `?open=` |
|
||||
@@ -28,6 +28,7 @@
|
||||
- **页头与工具栏**:一屏一个主操作,其余是描边胶囊;工具栏里的图标按钮是描边圆钮(`variant="outline" size="icon-sm"`)。`ghost` 只留给行内文字动作(返回链接、表格行尾的「⋯」、卡片里的「详情」「清除选择」)和顶栏胶囊里的按钮。下拉一律用 `Select`,不用原生 `<select>`。
|
||||
- **分页**(`Pagination`):一页放得下时只留「共 N 条」(超过最小档 10 条时保留每页条数),没有条目时不渲染;卡片网格下面传 `hideSingle`,一页时整块不显示。翻页按钮按容器宽度收起(`@container`),窄卡片里不折行。
|
||||
- **顶栏搜索**在右侧玻璃胶囊的第一格(与 uhd_web 流媒体顶栏一致):桌面是平涂淡底胶囊加 Ctrl K 提示,手机只留图标,都打开命令面板。
|
||||
- **层级与抽屉**:页面里最多一层页签 / 分段;从列表打开记录用右侧抽屉 `DetailSheet`、写进 `?open=`,抽屉里不再叠抽屉。规则只写在 [控制台信息架构 §9](console-ia.md#9-导航层级与记录抽屉2026-10-08现行标准)。
|
||||
|
||||
右上角账户模块全站共用 `account-popover.tsx`:用户中心用头像 + 名字的胶囊,流媒体与测速用圆头像;菜单里是另外两块的跳转(当前所在的不列,流媒体与测速需有生效订阅)与退出登录。
|
||||
|
||||
|
||||
Reference in new issue
Block a user