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

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

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

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

No files matched your search

+204 -156
View File
@@ -35,7 +35,7 @@ import {
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useSession } from "~/api/auth.ts";
import {
@@ -91,6 +91,7 @@ import {
} from "~/components/files/model.ts";
import { PathBreadcrumb } from "~/components/files/path-breadcrumb.tsx";
import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx";
import { TrashView } from "~/components/files/trash-view.tsx";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
@@ -212,12 +213,16 @@ function TreeRail({
onSelect,
onExpand,
trashCount,
trashOpen,
onTrash,
}: {
roots: FileRoot[];
current: string;
onSelect: (loc: Loc) => void;
onExpand: () => void;
trashCount: string;
trashOpen: boolean;
onTrash: () => void;
}) {
return (
<nav aria-label="登记目录" className="flex flex-col items-center gap-1 py-2.5">
@@ -234,11 +239,11 @@ function TreeRail({
type="button"
title={`${r.name}${r.health === "offline" ? " · 离线" : ""}`}
aria-label={r.name}
aria-current={current === r.id ? "location" : undefined}
aria-current={current === r.id && !trashOpen ? "location" : undefined}
onClick={() => onSelect({ root: r.id, path: "" })}
className={cn(
"relative grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
current === r.id && "pill-selected text-foreground",
current === r.id && !trashOpen && "pill-selected text-foreground",
)}
>
<Icon className="h-4 w-4" />
@@ -254,14 +259,19 @@ function TreeRail({
}),
)}
<span aria-hidden className="my-1 h-px w-6 bg-border" />
<Link
to="/console/files/trash"
<button
type="button"
title={`回收站 ${trashCount}`}
aria-label={`回收站 ${trashCount}`}
className="grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
aria-current={trashOpen ? "location" : undefined}
onClick={onTrash}
className={cn(
"grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
trashOpen && "pill-selected text-foreground",
)}
>
<Trash2 className="h-4 w-4" />
</Link>
</button>
</nav>
);
}
@@ -289,6 +299,8 @@ export default function FilesPage() {
const hidden = params.get("hidden") === "1";
const selParam = params.get("sel");
const identifyAuto = params.get("idf") === "1";
// 回收站是右侧的一个视图(?view=trash),不再单独一页
const trashView = params.get("view") === "trash";
const [qInput, setQInput] = useState(params.get("q") ?? "");
const q = params.get("q") ?? "";
const root = rootList.find((r) => r.id === rootId);
@@ -311,10 +323,10 @@ export default function FilesPage() {
// 初始位置:上次位置 > 第一个下载目录 > 第一个库根(§2 #2)
useEffect(() => {
if (rootId || !rootList.length) return;
if (rootId || trashView || !rootList.length) return;
const loc = initialLocation(rootList, readLastLoc());
if (loc) patch({ root: loc.root, path: loc.path || null }, true);
}, [rootId, rootList, patch]);
}, [rootId, trashView, rootList, patch]);
// 记住上次位置
useEffect(() => {
@@ -386,7 +398,7 @@ export default function FilesPage() {
const go = (loc: { root: string; path: string }) => {
setTreeSheet(false);
patch({ root: loc.root, path: loc.path || null, sel: null, idf: null, q: null });
patch({ root: loc.root, path: loc.path || null, sel: null, idf: null, q: null, view: null });
setQInput("");
};
const open = (e: FileEntry) => {
@@ -394,6 +406,10 @@ export default function FilesPage() {
if (e.type === "dir") go({ root: rootId, path: e.path });
else patch({ sel: e.path, idf: null });
};
const showTrash = () => {
setTreeSheet(false);
patch({ view: "trash", sel: null, idf: null });
};
const up = () => {
if (path) go({ root: rootId, path: parentRel(path) });
};
@@ -589,7 +605,12 @@ export default function FilesPage() {
{roots.isPending ? <LoadingState rows={4} /> : null}
{roots.error ? <ErrorState plain error={roots.error} onRetry={() => void roots.refetch()} /> : null}
{rootList.length ? (
<DirTree roots={rootList} current={root ? { root: root.id, path } : null} onSelect={go} onDropTo={onDropTo} />
<DirTree
roots={rootList}
current={root && !trashView ? { root: root.id, path } : null}
onSelect={go}
onDropTo={onDropTo}
/>
) : null}
{roots.data && !rootList.length ? (
<EmptyState
@@ -600,14 +621,19 @@ export default function FilesPage() {
className="px-2 py-8"
/>
) : null}
<Link
to="/console/files/trash"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-caption text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
<button
type="button"
aria-current={trashView ? "location" : undefined}
onClick={showTrash}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-caption text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
trashView && "pill-selected text-foreground",
)}
>
<Trash2 className="h-4 w-4" />
回收站
{trashCount && trashCount !== "0" ? <span className="ml-auto tabular-nums">{trashCount}</span> : null}
</Link>
</button>
</div>
);
@@ -895,7 +921,7 @@ export default function FilesPage() {
key: "trash",
label: `回收站${trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}`,
icon: Trash2,
onSelect: () => navigate("/console/files/trash"),
onSelect: showTrash,
},
];
@@ -937,6 +963,8 @@ export default function FilesPage() {
onSelect={go}
onExpand={() => setTreeOpenStored(true)}
trashCount={trashCount}
trashOpen={trashView}
onTrash={showTrash}
/>
)}
</div>
@@ -975,157 +1003,177 @@ export default function FilesPage() {
</aside>
) : null}
<section
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
aria-label="目录内容"
>
{/* 工具栏 */}
{mobile ? (
<div className="space-y-2 border-b border-border px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
<FolderTree />
</Button>
{searchOpen ? (
<div className="min-w-0 flex-1">{searchBox}</div>
) : root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="icon-sm"
aria-label={searchOpen ? "收起搜索" : "搜索"}
onClick={() => {
setSearchOpen((v) => !v);
if (!searchOpen) requestAnimationFrame(() => searchRef.current?.focus());
}}
>
{searchOpen ? <X /> : <Search />}
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="更多"
onClick={(ev) => openMenuAt(ev.currentTarget, mobileMore())}
>
<MoreHorizontal />
</Button>
</div>
{chips}
</div>
) : (
<div className="space-y-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label="上一级"
title="上一级(Alt+↑)"
disabled={!path}
onClick={up}
>
<ArrowUp />
</Button>
{root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="icon-sm"
aria-label="刷新"
title="刷新"
onClick={async () => {
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
void roots.refetch();
void list.refetch();
}}
>
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="快捷键"
title="快捷键(?)"
onClick={() => setDialog({ kind: "shortcuts" })}
>
<Keyboard />
</Button>
<span
title={
root?.writable
? undefined
: root?.kind === "download"
? "下载目录里不能新建目录"
: "只读目录,不能新建"
}
>
{trashView ? (
<section
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-y-auto rounded-2xl p-3 md:p-4"
aria-label="回收站"
>
<TrashView
leading={
mobile ? (
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
<FolderTree />
</Button>
) : null
}
/>
</section>
) : (
<section
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
aria-label="目录内容"
>
{/* 工具栏 */}
{mobile ? (
<div className="space-y-2 border-b border-border px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
<FolderTree />
</Button>
{searchOpen ? (
<div className="min-w-0 flex-1">{searchBox}</div>
) : root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="sm"
disabled={!root?.writable}
onClick={() => setDialog({ kind: "mkdir" })}
size="icon-sm"
aria-label={searchOpen ? "收起搜索" : "搜索"}
onClick={() => {
setSearchOpen((v) => !v);
if (!searchOpen) requestAnimationFrame(() => searchRef.current?.focus());
}}
>
<FolderPlus />
新建目录
{searchOpen ? <X /> : <Search />}
</Button>
</span>
{root ? <UploadButton root={root} path={path} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{searchBox}
<Button
variant="outline"
size="icon-sm"
aria-label="更多"
onClick={(ev) => openMenuAt(ev.currentTarget, mobileMore())}
>
<MoreHorizontal />
</Button>
</div>
{chips}
<div className="ml-auto">{sortControls}</div>
</div>
</div>
)}
) : (
<div className="space-y-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label="上一级"
title="上一级(Alt+↑)"
disabled={!path}
onClick={up}
>
<ArrowUp />
</Button>
{root ? (
<PathBreadcrumb
root={root}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="outline"
size="icon-sm"
aria-label="刷新"
title="刷新"
onClick={async () => {
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
void roots.refetch();
void list.refetch();
}}
>
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="快捷键"
title="快捷键(?)"
onClick={() => setDialog({ kind: "shortcuts" })}
>
<Keyboard />
</Button>
<span
title={
root?.writable
? undefined
: root?.kind === "download"
? "下载目录里不能新建目录"
: "只读目录,不能新建"
}
>
<Button
variant="outline"
size="sm"
disabled={!root?.writable}
onClick={() => setDialog({ kind: "mkdir" })}
>
<FolderPlus />
新建目录
</Button>
</span>
{root ? <UploadButton root={root} path={path} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{searchBox}
{chips}
<div className="ml-auto">{sortControls}</div>
</div>
</div>
)}
{first?.truncated ? (
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
</div>
) : null}
{first?.truncated ? (
<div
role="status"
className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning"
>
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
</div>
) : null}
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
{/* 状态栏 */}
{root && !offline && root.health !== "offline" && !mobile ? (
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
<span>
{first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"}
</span>
{selCount ? (
{/* 状态栏 */}
{root && !offline && root.health !== "offline" && !mobile ? (
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
<span>
已选 {selCount}
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
{first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"}
</span>
) : null}
{/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */}
{root.usage ? <span>剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null}
</div>
) : null}
{!mobile ? actionBar : null}
</section>
{selCount ? (
<span>
已选 {selCount}
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
</span>
) : null}
{/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */}
{root.usage ? <span>剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null}
</div>
) : null}
{!mobile ? actionBar : null}
</section>
)}
</div>
{mobile ? actionBar : null}
{mobile && !trashView ? actionBar : null}
{/* 手机:登记目录与目录树收进左侧抽屉 */}
<Sheet open={treeSheet} onOpenChange={setTreeSheet}>
@@ -1139,7 +1187,7 @@ export default function FilesPage() {
<ContextMenu anchor={menu?.at ?? null} items={menu?.items ?? []} onClose={() => setMenu(null)} />
{detail && root ? (
{detail && root && !trashView ? (
<FileDetail
root={root}
entry={detail}