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