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:
root committed 2026-10-08 14:13:32 +08:00
commit 78a4d562f0
60 files changed
+2764 -2461

No files matched your search

+136 -23
View File
@@ -1,54 +1,167 @@
import type { ReactNode } from "react";
import { ArrowLeft } from "lucide-react";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn } from "~/lib/utils.ts";
/** 桌面宽度:md ≈ 32rem,lg ≈ 44rem,xl ≈ 64rem(内容重的记录:订阅对比、站点);手机一律全宽 */
export type DetailSheetWidth = "md" | "lg" | "xl";
const WIDTH: Record<DetailSheetWidth, string> = {
md: "sm:max-w-lg",
lg: "sm:max-w-2xl",
xl: "sm:max-w-5xl",
};
/**
* 抽屉里再开的 DetailSheet 不叠第二层抽屉(console-ia.md §9):在同一个抽屉里换成子视图,
* 顶部是「返回」,关掉子视图回到原来的内容(滚动位置不丢)。
*/
type Nest = {
host: HTMLElement | null;
push: (close: () => void) => () => void;
};
const NestContext = createContext<Nest | null>(null);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
/** 标题下面一行的操作按钮(记录抽屉的主操作、更多) */
actions?: ReactNode;
children: ReactNode;
footer?: ReactNode;
width?: DetailSheetWidth;
className?: string;
};
/**
* 右侧详情抽屉(手机全宽)。内容区自带纵向滚动;footer 固定在底部放操作按钮。
* <DetailSheet open={!!id} onOpenChange={(o) => !o && setId(null)} title="详情" description="…">
* <KvList items={…} />
* </DetailSheet>
* 从列表打开记录时配合 useRecordSheet(record-sheet.tsx)把打开的记录写进地址栏。
*/
export function DetailSheet({
export function DetailSheet(props: Props) {
const nest = useContext(NestContext);
if (nest) return <NestedView {...props} nest={nest} />;
return <RootSheet {...props} />;
}
function RootSheet({
open,
onOpenChange,
title,
description,
actions,
children,
footer,
width = "md",
className,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
/** 桌面宽度:md ≈ 32rem,lg ≈ 44rem */
width?: "md" | "lg";
className?: string;
}) {
}: Props) {
const [host, setHost] = useState<HTMLDivElement | null>(null);
const stack = useRef<(() => void)[]>([]);
const [depth, setDepth] = useState(0);
const push = useCallback((close: () => void) => {
stack.current.push(close);
setDepth(stack.current.length);
return () => {
stack.current = stack.current.filter((c) => c !== close);
setDepth(stack.current.length);
};
}, []);
const nest = useMemo<Nest>(() => ({ host, push }), [host, push]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)}
className={cn("w-full gap-0 p-0", WIDTH[width], className)}
onEscapeKeyDown={(e) => {
// 有子视图时 Esc 先回到上一层
const top = stack.current.at(-1);
if (top) {
e.preventDefault();
top();
}
}}
>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
{description ? <SheetDescription className="break-words">{description}</SheetDescription> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{footer}
<NestContext.Provider value={nest}>
<div className={cn("flex min-h-0 flex-1 flex-col", depth > 0 && "invisible")} aria-hidden={depth > 0}>
<SheetFrame title={title} description={description} actions={actions} footer={footer}>
{children}
</SheetFrame>
</div>
) : null}
<div ref={setHost} className={cn("absolute inset-0 flex flex-col", depth === 0 && "hidden")} />
</NestContext.Provider>
</SheetContent>
</Sheet>
);
}
function SheetFrame({
title,
description,
actions,
footer,
children,
back,
}: Pick<Props, "title" | "description" | "actions" | "footer" | "children"> & { back?: () => void }) {
return (
<>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
{back ? (
<Button variant="ghost" size="sm" className="-ml-2 mb-1 self-start" onClick={back}>
<ArrowLeft />
返回
</Button>
) : null}
{back ? (
// 子视图在同一个对话框里,标题不再占用对话框的 Title / Description
<>
<h2 className="line-clamp-2 text-lead font-semibold break-words text-foreground">{title}</h2>
{description ? <div className="text-sm break-words text-muted-foreground">{description}</div> : null}
</>
) : (
<>
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
{description ? (
<SheetDescription asChild>
<div className="break-words">{description}</div>
</SheetDescription>
) : null}
</>
)}
{actions ? <div className="mt-2 flex flex-wrap items-center gap-2">{actions}</div> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{footer}
</div>
) : null}
</>
);
}
/** 抽屉里的子视图:渲染进外层抽屉的同一块区域,盖住原内容 */
function NestedView({ open, onOpenChange, nest, ...frame }: Props & { nest: Nest }) {
const close = useRef(() => onOpenChange(false));
close.current = () => onOpenChange(false);
const { push, host } = nest;
useEffect(() => {
if (!open) return;
return push(() => close.current());
}, [open, push]);
if (!open || !host) return null;
return createPortal(
<div className="flex min-h-0 flex-1 flex-col">
<SheetFrame {...frame} back={() => close.current()} />
</div>,
host,
);
}
/** 抽屉/卡片内的小节:大写小标签 + 内容 */
export function DetailSection({
title,
@@ -5,6 +5,7 @@ import { Link, useNavigate } from "react-router";
import { useAttention } from "~/api/attention.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { recordPath } from "~/components/console/record-sheet.tsx";
import { SEVERITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
@@ -24,7 +25,7 @@ export function approvalHref(d: Pick<Decision, "subjectType" | "subjectId" | "ag
if (d.agentRunId) return consolePath("agent", d.agentRunId);
if (d.subjectType === "acquisition_plan" && d.subjectId)
return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
if (d.subjectType === "download_task" && d.subjectId) return consolePath("downloads", d.subjectId);
if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId);
return consolePath("agent");
}
+27 -4
View File
@@ -10,6 +10,7 @@ import {
pageTitle,
settingsPath,
} from "./nav.ts";
import { recordPath, taskPath } from "./record-sheet.tsx";
/** routes.ts 里登记的控制台路由(相对 /console) */
const ROUTE_PATTERNS = [
@@ -69,7 +70,7 @@ describe("控制台导航", () => {
expect(findNavItem("/console")?.label).toBe("首页");
expect(findNavItem("/console/")?.label).toBe("首页");
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
// 任务详情独立页归日志
// 任务详情的旧地址归日志
expect(findNavItem("/console/tasks/r1")?.label).toBe("日志");
expect(pageTitle("/console/tasks/r1")).toBe("日志");
expect(findNavItem("/console/logs")?.label).toBe("日志");
@@ -103,17 +104,39 @@ describe("旧链接重定向", () => {
for (const { to } of LEGACY_REDIRECTS) {
const u = new URL(to, "http://x");
expect(matchesRoute(u.pathname), to).toBe(true);
if (u.pathname !== "/console/sites/abc") expect(legacyRedirect(u.pathname, u.search), to).toBeNull();
expect(legacyRedirect(u.pathname, u.search), to).toBeNull();
}
});
it("任务 / 审计 / 计划的旧地址一律落到日志页;任务详情独立页不跳", () => {
it("任务 / 审计 / 计划的旧地址一律落到日志页;任务详情落到日志页的任务抽屉", () => {
expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBe("/console/logs?type=task");
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBe("/console/logs?type=task&level=error");
expect(legacyRedirect("/console/tasks/r1", "")).toBeNull();
expect(legacyRedirect("/console/tasks/r1", "")).toBe("/console/logs?type=task&run=r1");
expect(legacyRedirect("/console/logs", "?type=task")).toBeNull();
});
it("记录详情的旧地址落到列表页的抽屉(?open=),其余参数跟在后面", () => {
expect(legacyRedirect("/console/organize/r%201", "")).toBe("/console/organize?open=r+1");
expect(legacyRedirect("/console/sites/abc", "?tab=accounts")).toBe("/console/sites?open=abc&tab=accounts");
expect(legacyRedirect("/console/sites/abc/", "?open=x")).toBe("/console/sites?open=abc");
expect(legacyRedirect("/console/downloads/plans", "")).toBe("/console/downloads");
expect(legacyRedirect("/console/files/trash", "")).toBe("/console/files?view=trash");
// 列表页本身和作品页不跳
expect(legacyRedirect("/console/organize", "?open=r1")).toBeNull();
expect(legacyRedirect("/console/library/abc", "")).toBeNull();
});
it("站内拼的记录地址都是列表页 + ?open=", () => {
expect(recordPath("subscriptions", "s 1")).toBe("/console/subscriptions?open=s+1");
expect(recordPath("sites", "abc", { tab: "rss", feed: null })).toBe("/console/sites?open=abc&tab=rss");
expect(taskPath("run-1")).toBe("/console/logs?type=task&run=run-1");
for (const to of [recordPath("organize", "x"), recordPath("downloads", "x"), taskPath("x")]) {
const u = new URL(to, "http://x");
expect(matchesRoute(u.pathname), to).toBe(true);
expect(legacyRedirect(u.pathname, u.search), to).toBeNull();
}
});
it("不相关的 query 不跳转", () => {
expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull();
expect(legacyRedirect("/console/subscriptions", "")).toBeNull();
@@ -0,0 +1,151 @@
import { useCallback, useRef } from "react";
import { Link, type LinkProps, type Location, useLocation, useNavigate, useSearchParams } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
// 记录抽屉的地址约定(docs/design/console-ia.md §9):从列表打开的记录 = 列表页上的右侧抽屉,
// 打开的记录写在 ?open=<id>(其余参数不动),刷新、分享、前进后退都能还原;
// 在本页打开是 push(后退关抽屉),关闭时如果是本页 push 的就后退一步,否则 replace 去掉参数。
/** 有记录抽屉的列表页 */
export type RecordPage = "organize" | "subscriptions" | "sites" | "downloads";
export const OPEN_PARAM = "open";
type Extra = Record<string, string | null | undefined>;
type SheetState = { sheetFrom?: string } | null;
function applyExtra(p: URLSearchParams, extra?: Extra) {
if (!extra) return;
for (const [k, v] of Object.entries(extra)) {
if (v) p.set(k, v);
else p.delete(k);
}
}
/** 打开某条记录的地址(从别的页面跳过来用):/console/<page>?open=<id>[&…] */
export function recordPath(page: RecordPage, id: string, extra?: Extra): string {
const q = new URLSearchParams();
q.set(OPEN_PARAM, id);
applyExtra(q, extra);
return `${consolePath(page)}?${q}`;
}
/** 任务(引擎运行)在日志页的抽屉里看:/console/logs?type=task&run=<id> */
export function taskPath(runId: string): string {
return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`;
}
/**
* 链接 / 跳转目标:在同一个列表页上时保留当前筛选、记下「本页打开」(关闭时后退)、不滚回顶部;
* 抽屉已开着时原地换成另一条(replace)。
*/
function recordTarget(location: Location, page: RecordPage, id: string, extra?: Extra) {
const base = consolePath(page);
const same = location.pathname === base;
if (!same) return { to: recordPath(page, id, extra), replace: false, state: undefined, preventScrollReset: false };
const q = new URLSearchParams(location.search);
const already = q.has(OPEN_PARAM);
q.set(OPEN_PARAM, id);
applyExtra(q, extra);
return {
to: `${base}?${q}`,
replace: already,
state: already ? location.state : ({ sheetFrom: base } satisfies SheetState),
preventScrollReset: true,
};
}
/** 指向一条记录的链接:落到所属列表页并打开抽屉 */
export function RecordLink({
page,
id,
extra,
...rest
}: Omit<LinkProps, "to" | "state" | "replace"> & { page: RecordPage; id: string; extra?: Extra }) {
const location = useLocation();
const t = recordTarget(location, page, id, extra);
return <Link {...rest} to={t.to} state={t.state} replace={t.replace} preventScrollReset={t.preventScrollReset} />;
}
/** 代码里跳到一条记录(菜单项、按钮) */
export function useOpenRecord() {
const location = useLocation();
const navigate = useNavigate();
return useCallback(
(page: RecordPage, id: string, extra?: Extra) => {
const t = recordTarget(location, page, id, extra);
navigate(t.to, { replace: t.replace, state: t.state, preventScrollReset: t.preventScrollReset });
},
[location, navigate],
);
}
/**
* 列表页上的记录抽屉状态。
* const sheet = useRecordSheet({ scoped: ["tab"] });
* <DataTable onRowClick={(r) => sheet.open(r.id)} selectedKey={sheet.id} />
* <DetailSheet open={!!sheet.id} onOpenChange={(o) => !o && sheet.close()} />
* key:记录参数名(缺省 open);scoped:只属于抽屉的参数(抽屉内页签等),关闭或换记录时一起去掉;
* drop:打开记录时要关掉的其它面板参数(同一时间只开一个抽屉)。
*/
export function useRecordSheet({
key = OPEN_PARAM,
scoped = [],
drop = [],
}: {
key?: string;
scoped?: readonly string[];
drop?: readonly string[];
} = {}) {
const [params] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const id = params.get(key);
// 日志页的任务可以只带 ?run=(scoped 参数),也算抽屉开着
const isOpen = !!id || scoped.some((k) => params.has(k));
const pushed = (location.state as SheetState)?.sheetFrom === location.pathname;
const go = (mutate: (p: URLSearchParams) => void, replace: boolean, state: unknown) => {
const next = new URLSearchParams(location.search);
mutate(next);
const s = next.toString();
navigate({ search: s ? `?${s}` : "", hash: location.hash }, { replace, state, preventScrollReset: true });
};
/** 打开一条;已开着时原地换成这条 */
const open = (nextId: string, extra?: Extra) =>
go(
(p) => {
for (const k of [...scoped, ...drop]) p.delete(k);
p.set(key, nextId);
applyExtra(p, extra);
},
isOpen,
isOpen ? location.state : ({ sheetFrom: location.pathname } satisfies SheetState),
);
/** 关闭:本页打开的后退一步(前进还能再打开),直接打开的链接去掉参数 */
const close = () => {
if (pushed) navigate(-1);
else
go(
(p) => {
p.delete(key);
for (const k of scoped) p.delete(k);
},
true,
null,
);
};
/** 改抽屉里的参数(页签等),不留历史 */
const patch = (extra: Extra) => go((p) => applyExtra(p, extra), true, location.state);
return { id, open, close, patch, params };
}
/** 抽屉关闭动画期间还显示刚才那条:open 用当前值,内容用这个 */
export function useStickyId(id: string | null): string | null {
const last = useRef(id);
if (id) last.current = id;
return last.current;
}