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
+45
View File
@@ -0,0 +1,45 @@
/**
* /database/api/bin/* 回收站端点(ADR-0031)。
* 约定:绝对路径;actorOrNull 前置;业务全走 binService;错误统一 sendRouteError。
*/
import type { FastifyInstance } from "fastify";
import { listBin, purgeBinEntry, restoreBinEntry } from "../filelib/binService.js";
import { actorOrNull, sendRouteError, type FileLibRouteDeps } from "../filelib/routeShared.js";
export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
app.get("/database/api/bin", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
return { entries: await listBin(svc, actor) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/bin/:id/restore", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
await restoreBinEntry(svc, actor, id);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/bin/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return await purgeBinEntry(svc, actor, id);
} catch (error) {
return sendRouteError(reply, error);
}
});
}
@@ -28,6 +28,8 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerBinRoutes } from "./binRoutes.js";
import { registerRecentRoutes } from "./recentRoutes.js";
import { registerTeacherApp } from "./teacherApp.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
@@ -161,6 +163,8 @@ export async function registerDatabaseRoutes(
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerBinRoutes(app, filelibDeps);
await registerRecentRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
+47
View File
@@ -0,0 +1,47 @@
/**
* /database/api/recent 最近打开端点(ADR-0031)。
* 约定:绝对路径;actorOrNull 前置;业务全走 recentService;错误统一 sendRouteError。
*/
import type { FastifyInstance } from "fastify";
import { listRecent, recordVisit } from "../filelib/recentService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerRecentRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
app.get("/database/api/recent", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
return { entries: await listRecent(svc, actor) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/recent", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const nodeId = requireString(body, "nodeId");
const filePath = optionalString(body, "filePath");
if (filePath !== undefined && filePath.trim() === "") {
throw new FileLibError(400, "invalid_request", "filePath must be non-empty when present");
}
await recordVisit(svc, actor, nodeId, filePath);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
}