feat(inbox): 素材持久化 CRUD — 数据库表 + API + 前端接入

- 新增 materials 表(id/projectId/description/associatedDate/status/createdAt/updatedAt)
- 新增 4 个后端 API 路由(list/create/get/delete)+ 13 个测试
- 新增 use-materials hooks(TanStack Query)
- 收集箱页面重构为三层架构(InboxPage + MaterialSidebar + MaterialDetailPanel)
- MaterialCard: Popconfirm 删除确认 + 粗粒度时间格式
- MaterialContent: 展示状态标签 + createdAt
- 更新开发文档 backend.md / frontend.md
This commit is contained in:
2026-06-03 14:53:23 +08:00
parent 5b09a16bc3
commit 21b557c255
29 changed files with 1629 additions and 116 deletions

View File

@@ -0,0 +1,283 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import type { Material, RuntimeMode } from "../../../src/shared/api";
import { archiveProject, createProject } from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function createMaterialViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateMaterial: h } = await import("../../../src/server/routes/materials/create");
return h(req, db, MODE, LOG);
}
function createTestProject(db: Database, name = "测试项目") {
const result = createProject(db, { name }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project;
}
async function deleteMaterialViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteMaterial: h } = await import("../../../src/server/routes/materials/delete");
return h(req, db, MODE, LOG);
}
async function getMaterialViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetMaterial: h } = await import("../../../src/server/routes/materials/get");
return h(req, db, MODE, LOG);
}
async function listMaterialsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListMaterials: h } = await import("../../../src/server/routes/materials/list");
return h(req, db, MODE, LOG);
}
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
const handle = createMigratedMemoryTestDatabase("material-route-test");
try {
await callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("素材 API 路由", () => {
describe("POST /api/projects/:id/materials", () => {
test("正常创建素材", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(201);
const body = (await res.json()) as { material: Material };
expect(body.material.description).toBe("测试素材");
expect(body.material.associatedDate).toBe("2024-01-15");
expect(body.material.projectId).toBe(project.id);
expect(body.material.status).toBe("pending");
});
});
test("缺少 description 返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("缺少 associatedDate 返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("associatedDate 格式错误返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024/01/15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("项目不存在返回 404", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/projects/nonexistent/materials", {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(404);
});
});
test("已归档项目返回 409", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
archiveProject(db, project.id, LOG);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(409);
});
});
});
describe("GET /api/projects/:id/materials", () => {
test("正常列表查询", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req1 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "素材1" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createMaterialViaHandler(req1, db);
const req2 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-16", description: "素材2" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createMaterialViaHandler(req2, db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
const res = await listMaterialsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Material[]; total: number };
expect(body.total).toBe(2);
expect(body.items.length).toBe(2);
});
});
test("空项目返回空列表", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
const res = await listMaterialsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Material[]; total: number };
expect(body.total).toBe(0);
expect(body.items.length).toBe(0);
});
});
});
describe("GET /api/projects/:id/materials/:mid", () => {
test("正常获取详情", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "详情测试" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
const res = await getMaterialViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { material: Material };
expect(body.material.description).toBe("详情测试");
});
});
test("归属错误返回 403", async () => {
await withRouteDb(async (db) => {
const projectA = createTestProject(db, "项目A");
const projectB = createProject(db, { name: "项目B" }, LOG);
if ("error" in projectB) throw new Error(projectB.error);
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
);
const res = await getMaterialViaHandler(req, db);
expect(res.status).toBe(403);
});
});
});
describe("DELETE /api/projects/:id/materials/:mid", () => {
test("正常删除", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
method: "DELETE",
});
const res = await deleteMaterialViaHandler(req, db);
expect(res.status).toBe(204);
});
});
test("删除后再查返回 404", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const delReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
method: "DELETE",
});
await deleteMaterialViaHandler(delReq, db);
const getReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
const res = await getMaterialViaHandler(getReq, db);
expect(res.status).toBe(404);
});
});
test("归属错误返回 403", async () => {
await withRouteDb(async (db) => {
const projectA = createTestProject(db, "项目A");
const projectB = createProject(db, { name: "项目B" }, LOG);
if ("error" in projectB) throw new Error(projectB.error);
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
{
method: "DELETE",
},
);
const res = await deleteMaterialViaHandler(req, db);
expect(res.status).toBe(403);
});
});
});
});