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:
107
src/server/db/materials.ts
Normal file
107
src/server/db/materials.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { materials, projects } from "./schema";
|
||||
|
||||
export function createMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
request: CreateMaterialRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const project = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!project) return { error: "项目不存在", status: 404 };
|
||||
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
||||
|
||||
const description = request.description.trim();
|
||||
if (!description) return { error: "描述不能为空", status: 400 };
|
||||
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(request.associatedDate)) {
|
||||
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.insert(materials)
|
||||
.values({
|
||||
associatedDate: request.associatedDate,
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
projectId,
|
||||
status: "pending",
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(materials).where(eq(materials.id, id)).get();
|
||||
return { material: toMaterial(row!) };
|
||||
}
|
||||
|
||||
export function deleteMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
|
||||
db.delete(materials).where(eq(materials.id, materialId)).run();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
|
||||
return { material: toMaterial(row) };
|
||||
}
|
||||
|
||||
export function listMaterials(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
options: { page: number; pageSize: number; status?: MaterialStatus },
|
||||
): { items: Material[]; page: number; pageSize: number; total: number } {
|
||||
const conditions = [eq(materials.projectId, projectId)];
|
||||
|
||||
if (options.status) {
|
||||
conditions.push(eq(materials.status, options.status));
|
||||
}
|
||||
|
||||
return paginateQuery(raw, materials, {
|
||||
conditions,
|
||||
mapRow: toMaterial,
|
||||
orderBy: () => desc(materials.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
function toMaterial(row: typeof materials.$inferSelect): Material {
|
||||
return {
|
||||
associatedDate: row.associatedDate,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -62,6 +62,24 @@ export const conversations = sqliteTable(
|
||||
(table) => [index("conversations_project_id_idx").on(table.projectId)],
|
||||
);
|
||||
|
||||
export const materials = sqliteTable(
|
||||
"materials",
|
||||
{
|
||||
associatedDate: text("associated_date").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
description: text("description").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [index("materials_project_id_idx").on(table.projectId)],
|
||||
);
|
||||
|
||||
export const messages = sqliteTable(
|
||||
"messages",
|
||||
{
|
||||
|
||||
45
src/server/routes/materials/create.ts
Normal file
45
src/server/routes/materials/create.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { CreateMaterialRequest, RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { createMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleCreateMaterial(
|
||||
req: Request,
|
||||
db: Database,
|
||||
mode: RuntimeMode,
|
||||
logger: Logger,
|
||||
): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const projectIdStr = parseIdFromUrl(url);
|
||||
|
||||
const validated = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: CreateMaterialRequest;
|
||||
try {
|
||||
body = (await req.json()) as CreateMaterialRequest;
|
||||
} catch (e: unknown) {
|
||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.description || typeof body.description !== "string") {
|
||||
return jsonResponse(createApiError("description is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.associatedDate || typeof body.associatedDate !== "string") {
|
||||
return jsonResponse(createApiError("associatedDate is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = createMaterial(db, validated.id, body, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: result.material.id, projectId: validated.id }, "素材创建成功");
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
29
src/server/routes/materials/delete.ts
Normal file
29
src/server/routes/materials/delete.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { deleteMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDeleteMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/");
|
||||
const projectIdStr = parts[3];
|
||||
const materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = deleteMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材删除成功");
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
28
src/server/routes/materials/get.ts
Normal file
28
src/server/routes/materials/get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { getMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleGetMaterial(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/");
|
||||
const projectIdStr = parts[3];
|
||||
const materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = getMaterial(db, validatedProject.id, validatedMaterial.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
35
src/server/routes/materials/list.ts
Normal file
35
src/server/routes/materials/list.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { listMaterials } from "../../db/materials";
|
||||
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||
import { validateIdParam, validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListMaterials(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const projectIdStr = parseIdFromUrl(url);
|
||||
|
||||
const validated = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const statusParam = url.searchParams.get("status");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
if (statusParam && statusParam !== "pending" && statusParam !== "approved" && statusParam !== "discarded") {
|
||||
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = listMaterials(db, validated.id, {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
status: (statusParam as "approved" | "discarded" | "pending") ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
@@ -220,6 +220,42 @@ export function startServer(options: StartServerOptions) {
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials": {
|
||||
GET: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleListMaterials } = await import("./routes/materials/list");
|
||||
return handleListMaterials(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleCreateMaterial } = await import("./routes/materials/create");
|
||||
return handleCreateMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials/:mid": {
|
||||
DELETE: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleDeleteMaterial } = await import("./routes/materials/delete");
|
||||
return handleDeleteMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
GET: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleGetMaterial } = await import("./routes/materials/get");
|
||||
return handleGetMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/restore": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
|
||||
Reference in New Issue
Block a user