feat(filelib): 老师端左栏导航:回收站 + 最近打开(ADR-0031)

- 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、
  restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举
  子树按深度降序分批硬删,绕过 self-FK RESTRICT)
- 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端
  成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤
- 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView;
  GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带)
- 测试:filelib-nav 集成 4 例;全套 79 例绿
This commit is contained in:
ymy
2026-07-31 13:27:04 +08:00
parent 04fa383286
commit d072e9ec1e
17 changed files with 998 additions and 17 deletions
+70 -10
View File
@@ -23,14 +23,23 @@
import GrantsPanel from "./GrantsPanel.svelte";
import OverviewPanel from "./OverviewPanel.svelte";
/** 最近打开上报的导航目标(ADR-0031):父组件传入后,本组件跳到对应节点并清除。 */
export interface NavTarget {
readonly nodeId: string;
readonly filePath?: string | undefined;
}
let { navTarget = null, onnavigated }: { navTarget?: NavTarget | null; onnavigated?: () => void } = $props();
const RANK: Record<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
type View = "nodes" | "files";
type StackItem = Pick<NodeChild, "id" | "name" | "kind" | "role">;
let view = $state<View>("nodes");
/** 下钻栈(均为 FOLDER;根层为空栈)。 */
let stack = $state<NodeChild[]>([]);
let stack = $state<StackItem[]>([]);
let children = $state<NodeChild[] | null>(null);
let nodesError = $state<string | null>(null);
@@ -48,7 +57,7 @@
let createParentId = $state<string | null>(null);
let formName = $state("");
let formDesc = $state("");
let renameTarget = $state<NodeChild | null>(null);
let renameTarget = $state<StackItem | null>(null);
let detailNode = $state<NodeDetail | null>(null);
let newPath = $state("");
let newContent = $state("");
@@ -98,6 +107,14 @@
onMount(loadChildren);
/** 最近打开上报(ADR-0031):fire-and-forget,失败静默,不阻塞浏览。 */
function record(nodeId: string, filePath?: string): void {
void api("/database/api/recent", {
method: "POST",
body: { nodeId, ...(filePath !== undefined ? { filePath } : {}) },
}).catch(() => undefined);
}
function refresh(): void {
selected = null;
menu = null;
@@ -107,8 +124,9 @@
/* ------------------------------------------------------------ 导航 */
function openNode(n: NodeChild): void {
function openNode(n: StackItem): void {
selected = null;
record(n.id);
if (n.kind === "FOLDER") {
stack = [...stack, n];
void loadChildren();
@@ -156,6 +174,43 @@
void loadChildren();
}
/** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */
async function navigateTo(target: NavTarget): Promise<void> {
try {
const r = await api<{ breadcrumb: Array<{ id: string | null; name: string | null; kind: "FOLDER" | "PROJECT"; role: Role | null }> }>(
`/database/api/nodes/${target.nodeId}/breadcrumb`,
);
const visible = r.breadcrumb.filter(
(e): e is { id: string; name: string; kind: "FOLDER" | "PROJECT"; role: Role | null } =>
e.id !== null && e.name !== null,
);
if (visible.length === 0) return;
const self = visible[visible.length - 1]!;
selected = null;
if (self.kind === "FOLDER") {
view = "nodes";
projectNode = null;
clearSelectedFile();
stack = visible.map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" }));
await loadChildren();
} else {
stack = visible.slice(0, -1).map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" }));
projectNode = await fetchDetail(self.id);
view = "files";
await loadFiles();
if (target.filePath !== undefined) selectedFilePath.set(target.filePath);
}
} catch (e) {
toastErr(errText(e));
}
}
$effect(() => {
if (navTarget === null) return;
const t = navTarget;
void navigateTo(t).finally(() => onnavigated?.());
});
/* ------------------------------------------------------------ 节点操作 */
function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void {
@@ -190,7 +245,7 @@
}
}
function openRename(n: NodeChild): void {
function openRename(n: StackItem): void {
renameTarget = n;
formName = n.name;
modal = "rename";
@@ -213,7 +268,7 @@
}
}
async function removeNode(n: NodeChild): Promise<void> {
async function removeNode(n: StackItem): Promise<void> {
if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${n.id}`, { method: "DELETE" });
@@ -224,7 +279,7 @@
}
}
async function openGrants(n: NodeChild): Promise<void> {
async function openGrants(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "grants";
@@ -233,7 +288,7 @@
}
}
async function openDetail(n: NodeChild): Promise<void> {
async function openDetail(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "detail";
@@ -244,6 +299,11 @@
/* ------------------------------------------------------------ 文件操作 */
function previewFile(f: FileEntry): void {
if (projectNode !== null) record(projectNode.id, f.path);
selectedFilePath.set(f.path);
}
async function submitNewFile(): Promise<void> {
if (projectNode === null) return;
const path = newPath.trim();
@@ -285,7 +345,7 @@
/* ------------------------------------------------------------ 右键菜单 */
function nodeMenuItems(n: NodeChild): MenuItem[] {
function nodeMenuItems(n: StackItem): MenuItem[] {
const items: MenuItem[] = [{ label: "打开", icon: "chevron", onclick: () => openNode(n) }];
if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) {
items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) });
@@ -305,7 +365,7 @@
function fileMenuItems(f: FileEntry): MenuItem[] {
const items: MenuItem[] = [
{ label: "打开预览", icon: "chevron", onclick: () => selectedFilePath.set(f.path) },
{ label: "打开预览", icon: "chevron", onclick: () => previewFile(f) },
{
label: "下载",
icon: "download",
@@ -441,7 +501,7 @@
meta="{f.size} B"
selected={selected === f.path}
onselect={() => (selected = f.path)}
onopen={() => selectedFilePath.set(f.path)}
onopen={() => previewFile(f)}
oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })}
/>
{/each}