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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user