feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。
- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
dd0f6e2c09
commit
21c55dfb3a
60 files changed
+2768
-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,
|
||||
|
||||
Reference in new issue
Block a user