From 21b557c255423a4b0bac3067215bfc23c5b6ecf0 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 3 Jun 2026 14:53:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(inbox):=20=E7=B4=A0=E6=9D=90=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20CRUD=20=E2=80=94=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=A1=A8=20+=20API=20+=20=E5=89=8D=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- docs/development/backend.md | 12 + docs/development/frontend.md | 17 +- drizzle/0003_lying_cassandra_nova.sql | 12 + drizzle/meta/0003_snapshot.json | 499 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/server/db/materials.ts | 107 ++++ src/server/db/schema.ts | 18 + src/server/routes/materials/create.ts | 45 ++ src/server/routes/materials/delete.ts | 29 + src/server/routes/materials/get.ts | 28 + src/server/routes/materials/list.ts | 35 ++ src/server/server.ts | 36 ++ src/shared/api.ts | 28 + .../inbox/components/AddMaterialModal.tsx | 28 +- .../inbox/components/MaterialCard.tsx | 55 +- .../inbox/components/MaterialContent.tsx | 21 +- .../inbox/components/MaterialDetailPanel.tsx | 51 ++ .../inbox/components/MaterialList.tsx | 9 +- .../inbox/components/MaterialSidebar.tsx | 44 ++ src/web/features/inbox/index.tsx | 32 +- src/web/features/inbox/types.ts | 7 +- src/web/shared/hooks/use-materials.ts | 95 ++++ src/web/styles.css | 5 +- tests/server/routes/materials.test.ts | 283 ++++++++++ .../features/inbox/AddMaterialModal.test.tsx | 43 +- tests/web/features/inbox/InboxPage.test.tsx | 113 +++- .../web/features/inbox/MaterialCard.test.tsx | 30 +- .../features/inbox/MaterialContent.test.tsx | 27 +- .../web/features/inbox/MaterialList.test.tsx | 29 +- 29 files changed, 1629 insertions(+), 116 deletions(-) create mode 100644 drizzle/0003_lying_cassandra_nova.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/server/db/materials.ts create mode 100644 src/server/routes/materials/create.ts create mode 100644 src/server/routes/materials/delete.ts create mode 100644 src/server/routes/materials/get.ts create mode 100644 src/server/routes/materials/list.ts create mode 100644 src/web/features/inbox/components/MaterialDetailPanel.tsx create mode 100644 src/web/features/inbox/components/MaterialSidebar.tsx create mode 100644 src/web/shared/hooks/use-materials.ts create mode 100644 tests/server/routes/materials.test.ts diff --git a/docs/development/backend.md b/docs/development/backend.md index 1082ab0..dd0895b 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -30,6 +30,7 @@ SQLite + bun:sqlite + Drizzle ORM。 | `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider | | `models.ts` | createModel、getModel、listModels、getModelWithProvider、getModelsByProviderId、updateModel、deleteModel | | `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages | +| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial | 输入输出类型来自 `src/shared/api.ts`。 @@ -56,6 +57,17 @@ SQLite + bun:sqlite + Drizzle ORM。 - `POST /api/providers/test` — 用未保存配置测试,不写入 DB,不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false`;`/models` 不支持返回 `ok: true` + 提示。 - `POST /api/models/test` — 用模型关联供应商 + modelId 测试。 +## 素材 API + +| 方法 | 路径 | 说明 | +| ------ | ---------------------------------- | ---------------------- | +| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) | +| POST | `/api/projects/:id/materials` | 创建素材 | +| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 | +| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(硬删除) | + +校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active,素材归属校验不匹配返回 403。 + ## 聊天 API | 方法 | 路径 | 说明 | diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 4c07c92..6fa65d9 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -21,16 +21,18 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si | 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索 | | 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 | | 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 | +| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 | ## 页面 -| 页面 | 路径 | 入口 | -| -------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 总览 | `/` | `features/dashboard/index.tsx` | -| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 | -| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 | -| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` | -| 404 | `*` | `features/not-found/index.tsx` | +| 页面 | 路径 | 入口 | +| -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 总览 | `/` | `features/dashboard/index.tsx` | +| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 | +| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 | +| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` | +| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 | +| 404 | `*` | `features/not-found/index.tsx` | ### 聊天页面 @@ -64,6 +66,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si | `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 | | `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 | | `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) | +| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) | ### 共享工具函数 diff --git a/drizzle/0003_lying_cassandra_nova.sql b/drizzle/0003_lying_cassandra_nova.sql new file mode 100644 index 0000000..ee10f8c --- /dev/null +++ b/drizzle/0003_lying_cassandra_nova.sql @@ -0,0 +1,12 @@ +CREATE TABLE `materials` ( + `associated_date` text NOT NULL, + `created_at` text NOT NULL, + `description` text NOT NULL, + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..c3c3501 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,499 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "340f6d1a-081b-413d-a289-f39592ece0a2", + "prevId": "da8963db-526e-46a1-a453-4027d5541db9", + "tables": { + "conversations": { + "name": "conversations", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'新会话'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_model_id_models_id_fk": { + "name": "conversations_model_id_models_id_fk", + "tableFrom": "conversations", + "tableTo": "models", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "materials": { + "name": "materials", + "columns": { + "associated_date": { + "name": "associated_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "materials_project_id_idx": { + "name": "materials_project_id_idx", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "materials_project_id_projects_id_fk": { + "name": "materials_project_id_projects_id_fk", + "tableFrom": "materials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": ["conversation_id"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "models": { + "name": "models", + "columns": { + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "models_provider_id_model_id_unique": { + "name": "models_provider_id_model_id_unique", + "columns": ["provider_id", "model_id"], + "isUnique": true + }, + "models_provider_id_idx": { + "name": "models_provider_id_idx", + "columns": ["provider_id"], + "isUnique": false + } + }, + "foreignKeys": { + "models_provider_id_providers_id_fk": { + "name": "models_provider_id_providers_id_fk", + "tableFrom": "models", + "tableTo": "providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "providers": { + "name": "providers", + "columns": { + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'openai-compatible'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "providers_name_unique": { + "name": "providers_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_migrations": { + "name": "schema_migrations", + "columns": { + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index be68d0e..1eb0b5e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1780162361636, "tag": "0002_orange_black_knight", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1780463734721, + "tag": "0003_lying_cassandra_nova", + "breakpoints": true } ] } diff --git a/src/server/db/materials.ts b/src/server/db/materials.ts new file mode 100644 index 0000000..9529934 --- /dev/null +++ b/src/server/db/materials.ts @@ -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, + }; +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 0f60f15..c6984e7 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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", { diff --git a/src/server/routes/materials/create.ts b/src/server/routes/materials/create.ts new file mode 100644 index 0000000..72be848 --- /dev/null +++ b/src/server/routes/materials/create.ts @@ -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 { + 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 }); +} diff --git a/src/server/routes/materials/delete.ts b/src/server/routes/materials/delete.ts new file mode 100644 index 0000000..7195d3d --- /dev/null +++ b/src/server/routes/materials/delete.ts @@ -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 }); +} diff --git a/src/server/routes/materials/get.ts b/src/server/routes/materials/get.ts new file mode 100644 index 0000000..f5a3c72 --- /dev/null +++ b/src/server/routes/materials/get.ts @@ -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 }); +} diff --git a/src/server/routes/materials/list.ts b/src/server/routes/materials/list.ts new file mode 100644 index 0000000..75599a3 --- /dev/null +++ b/src/server/routes/materials/list.ts @@ -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 }); +} diff --git a/src/server/server.ts b/src/server/server.ts index 5252808..0f97313 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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) => { diff --git a/src/shared/api.ts b/src/shared/api.ts index fbafd77..45294fd 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -28,6 +28,11 @@ export interface CreateConversationRequest { title?: string; } +export interface CreateMaterialRequest { + associatedDate: string; + description: string; +} + export interface CreateModelRequest { capabilities: ModelCapability[]; contextLength?: null | number; @@ -54,6 +59,29 @@ export interface CreateProviderRequest { // 前后端共享的类型都放在这个文件中 // ========================================== +export interface Material { + associatedDate: string; + createdAt: string; + description: string; + id: string; + projectId: string; + status: MaterialStatus; + updatedAt: string; +} + +export interface MaterialListResponse { + items: Material[]; + page: number; + pageSize: number; + total: number; +} + +export interface MaterialResponse { + material: Material; +} + +export type MaterialStatus = "approved" | "discarded" | "pending"; + export interface Message { content: string; conversationId: string; diff --git a/src/web/features/inbox/components/AddMaterialModal.tsx b/src/web/features/inbox/components/AddMaterialModal.tsx index da10cf1..053feca 100644 --- a/src/web/features/inbox/components/AddMaterialModal.tsx +++ b/src/web/features/inbox/components/AddMaterialModal.tsx @@ -1,11 +1,11 @@ import { App as AntApp, DatePicker, Form, Input, Modal } from "antd"; import dayjs from "dayjs"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; -import type { Material } from "../types"; +import type { CreateMaterialRequest, Material } from "../types"; interface AddMaterialModalProps { - onAdd: (material: Material) => void; + onAdd: (body: CreateMaterialRequest) => Promise; onOpenChange: (open: boolean) => void; open: boolean; } @@ -18,26 +18,34 @@ interface FormValues { export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) { const { message } = AntApp.useApp(); const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); useEffect(() => { if (!open) return; form.resetFields(); }, [form, open]); - const handleFinish = (values: FormValues) => { - const material: Material = { + const handleFinish = async (values: FormValues) => { + const body: CreateMaterialRequest = { associatedDate: values.associatedDate.format("YYYY-MM-DD"), - createdAt: new Date().toISOString(), description: values.description, - id: crypto.randomUUID(), }; - onAdd(material); - message.success("素材已添加"); - onOpenChange(false); + + setSubmitting(true); + try { + await onAdd(body); + message.success("素材已添加"); + onOpenChange(false); + } catch (e: unknown) { + message.error(`添加失败:${e instanceof Error ? e.message : "未知错误"}`); + } finally { + setSubmitting(false); + } }; return ( onOpenChange(false)} diff --git a/src/web/features/inbox/components/MaterialCard.tsx b/src/web/features/inbox/components/MaterialCard.tsx index 61adc12..e09f303 100644 --- a/src/web/features/inbox/components/MaterialCard.tsx +++ b/src/web/features/inbox/components/MaterialCard.tsx @@ -1,10 +1,8 @@ import { DeleteOutlined } from "@ant-design/icons"; -import { Button, Card, Flex, Typography } from "antd"; +import { Button, Card, Flex, Popconfirm, Typography } from "antd"; import type { Material } from "../types"; -import { formatRelativeTime } from "../../../shared/utils/time"; - interface MaterialCardProps { material: Material; onDelete: () => void; @@ -12,26 +10,49 @@ interface MaterialCardProps { selected: boolean; } -export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) { +export function MaterialCard({ material, onDelete, onSelect }: MaterialCardProps) { return ( - + {material.description} - - {material.associatedDate} · {formatRelativeTime(material.createdAt)} - -
- {materials.length === 0 ? ( + {loading ? ( + + ) : materials.length === 0 ? ( ) : ( materials.map((material) => ( diff --git a/src/web/features/inbox/components/MaterialSidebar.tsx b/src/web/features/inbox/components/MaterialSidebar.tsx new file mode 100644 index 0000000..72f76fe --- /dev/null +++ b/src/web/features/inbox/components/MaterialSidebar.tsx @@ -0,0 +1,44 @@ +import { Result } from "antd"; + +import { useDeleteMaterial, useMaterialList } from "../../../shared/hooks/use-materials"; +import { MaterialList } from "./MaterialList"; + +interface MaterialSidebarProps { + onAddClick: () => void; + onDelete: (id: string) => void; + onSelect: (id: string) => void; + projectId: string; + selectedId: null | string; +} + +export function MaterialSidebar({ onAddClick, onDelete, onSelect, projectId, selectedId }: MaterialSidebarProps) { + const { data, error, isLoading, refetch } = useMaterialList(projectId); + const deleteMutation = useDeleteMaterial(projectId); + + const handleDelete = (id: string) => { + void deleteMutation.mutate({ materialId: id, projectId }, { onSuccess: () => onDelete(id) }); + }; + + if (error) { + return ( +
+ void refetch()}>重试} + status="error" + subTitle="加载素材列表失败" + /> +
+ ); + } + + return ( + + ); +} diff --git a/src/web/features/inbox/index.tsx b/src/web/features/inbox/index.tsx index 5b867c5..eabc8e7 100644 --- a/src/web/features/inbox/index.tsx +++ b/src/web/features/inbox/index.tsx @@ -1,39 +1,43 @@ import { useState } from "react"; -import type { Material } from "./types"; +import type { CreateMaterialRequest, Material } from "./types"; +import { useCurrentProject } from "../../shared/hooks/use-current-project"; +import { useCreateMaterial } from "../../shared/hooks/use-materials"; import { AddMaterialModal } from "./components/AddMaterialModal"; -import { MaterialContent } from "./components/MaterialContent"; -import { MaterialList } from "./components/MaterialList"; +import { MaterialDetailPanel } from "./components/MaterialDetailPanel"; +import { MaterialSidebar } from "./components/MaterialSidebar"; export function InboxPage() { - const [materials, setMaterials] = useState([]); + const project = useCurrentProject(); const [modalOpen, setModalOpen] = useState(false); const [selectedId, setSelectedId] = useState(null); - const selectedMaterial = materials.find((m) => m.id === selectedId) ?? null; + const createMutation = useCreateMaterial(project.id); - const handleAdd = (material: Material) => { - setMaterials((prev) => [...prev, material].sort((a, b) => b.associatedDate.localeCompare(a.associatedDate))); + const handleCreate = async (body: CreateMaterialRequest): Promise => { + const material = await createMutation.mutateAsync({ body, projectId: project.id }); setSelectedId(material.id); + return material; }; - const handleDelete = (id: string) => { - setMaterials((prev) => prev.filter((m) => m.id !== id)); - if (selectedId === id) setSelectedId(null); + const handleDelete = (_id: string) => { + if (selectedId === _id) { + setSelectedId(null); + } }; return (
- setModalOpen(true)} onDelete={handleDelete} onSelect={setSelectedId} + projectId={project.id} selectedId={selectedId} /> - - + +
); } diff --git a/src/web/features/inbox/types.ts b/src/web/features/inbox/types.ts index af60d30..29931ae 100644 --- a/src/web/features/inbox/types.ts +++ b/src/web/features/inbox/types.ts @@ -1,6 +1 @@ -export interface Material { - associatedDate: string; - createdAt: string; - description: string; - id: string; -} +export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api"; diff --git a/src/web/shared/hooks/use-materials.ts b/src/web/shared/hooks/use-materials.ts new file mode 100644 index 0000000..5815589 --- /dev/null +++ b/src/web/shared/hooks/use-materials.ts @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { + CreateMaterialRequest, + Material, + MaterialListResponse, + MaterialResponse, + MaterialStatus, +} from "../../../shared/api"; + +import { handleResponse, handleVoidResponse } from "../utils/api"; +import { createConsoleLogger } from "../utils/logger"; + +const MATERIALS_KEY = ["materials"] as const; +const logger = createConsoleLogger(); + +export function createMaterial(args: { body: CreateMaterialRequest; projectId: string }): Promise { + const response = fetch(`/api/projects/${args.projectId}/materials`, { + body: JSON.stringify(args.body), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material)); +} + +export function deleteMaterial(args: { materialId: string; projectId: string }): Promise { + const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`, { method: "DELETE" }); + return response.then(handleVoidResponse); +} + +export async function fetchMaterial(args: { materialId: string; projectId: string }): Promise { + const response = await fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`); + return handleResponse(response, (data) => (data as MaterialResponse).material); +} + +export function fetchMaterials( + projectId: string, + params?: { page?: number; pageSize?: number; status?: MaterialStatus }, +): Promise { + const searchParams = new URLSearchParams(); + if (params?.page) searchParams.set("page", String(params.page)); + if (params?.pageSize) searchParams.set("pageSize", String(params.pageSize)); + if (params?.status) searchParams.set("status", params.status); + const qs = searchParams.toString(); + const url = `/api/projects/${projectId}/materials${qs ? `?${qs}` : ""}`; + const response = fetch(url); + return response.then((r) => { + if (!r.ok) { + return r.json().then((body: null | { error?: string }) => { + throw new Error(body?.error ?? `HTTP ${r.status}`); + }); + } + return r.json() as Promise; + }); +} + +export function useCreateMaterial(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createMaterial, + onSuccess: (data) => { + logger.info("素材创建成功", { materialId: data.id, projectId }); + void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] }); + }, + }); +} + +export function useDeleteMaterial(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteMaterial, + onSuccess: (_data, variables) => { + logger.info("素材删除成功", { materialId: variables.materialId, projectId }); + void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] }); + }, + }); +} + +export function useMaterial(args: { materialId: null | string; projectId: string }) { + return useQuery({ + enabled: !!args.materialId, + queryFn: () => fetchMaterial({ materialId: args.materialId!, projectId: args.projectId }), + queryKey: [...MATERIALS_KEY, "detail", args.projectId, args.materialId], + }); +} + +export function useMaterialList( + projectId: string, + params?: { page?: number; pageSize?: number; status?: MaterialStatus }, +) { + return useQuery({ + queryFn: () => fetchMaterials(projectId, params), + queryKey: [...MATERIALS_KEY, "list", projectId, params], + }); +} diff --git a/src/web/styles.css b/src/web/styles.css index 14e5d02..0257a98 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -278,6 +278,7 @@ body { gap: var(--ant-margin-sm); padding: var(--ant-padding-sm); border-right: 1px solid var(--ant-color-border-secondary); + border-radius: var(--ant-border-radius-lg); background: var(--ant-color-bg-container); } @@ -300,10 +301,6 @@ body { overflow-y: auto; } -.app-inbox-card-selected { - box-shadow: 0 0 0 1px var(--ant-color-primary); -} - .app-inbox-datepicker { width: 100%; } diff --git a/tests/server/routes/materials.test.ts b/tests/server/routes/materials.test.ts new file mode 100644 index 0000000..0dc74d6 --- /dev/null +++ b/tests/server/routes/materials.test.ts @@ -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 { + 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 { + const { handleDeleteMaterial: h } = await import("../../../src/server/routes/materials/delete"); + return h(req, db, MODE, LOG); +} + +async function getMaterialViaHandler(req: Request, db: Database): Promise { + const { handleGetMaterial: h } = await import("../../../src/server/routes/materials/get"); + return h(req, db, MODE, LOG); +} + +async function listMaterialsViaHandler(req: Request, db: Database): Promise { + const { handleListMaterials: h } = await import("../../../src/server/routes/materials/list"); + return h(req, db, MODE, LOG); +} + +async function withRouteDb(callback: (db: Database) => Promise): Promise { + 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); + }); + }); + }); +}); diff --git a/tests/web/features/inbox/AddMaterialModal.test.tsx b/tests/web/features/inbox/AddMaterialModal.test.tsx index 2331f5c..7d23da1 100644 --- a/tests/web/features/inbox/AddMaterialModal.test.tsx +++ b/tests/web/features/inbox/AddMaterialModal.test.tsx @@ -2,11 +2,21 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, test, vi } from "bun:test"; import { createElement } from "react"; -import type { Material } from "../../../../src/web/features/inbox/types"; +import type { CreateMaterialRequest, Material } from "../../../../src/shared/api"; import { AddMaterialModal } from "../../../../src/web/features/inbox/components/AddMaterialModal"; import { renderWithProviders } from "../../test-utils"; +const MOCK_CREATED: Material = { + associatedDate: "2026-06-03", + createdAt: "2026-06-03T00:00:00.000Z", + description: "测试描述", + id: "new-id", + projectId: "project-1", + status: "pending", + updatedAt: "2026-06-03T00:00:00.000Z", +}; + describe("AddMaterialModal", () => { test("打开时渲染表单字段", () => { renderWithProviders( @@ -49,7 +59,8 @@ describe("AddMaterialModal", () => { }); test("点击确定触发表单提交", async () => { - const onAdd = vi.fn<(material: Material) => void>(); + const onAdd = vi.fn<(body: CreateMaterialRequest) => Promise>(); + onAdd.mockResolvedValue(MOCK_CREATED); renderWithProviders( createElement(AddMaterialModal, { onAdd, @@ -69,9 +80,29 @@ describe("AddMaterialModal", () => { const callArgs = onAdd.mock.calls[0]; expect(callArgs).toBeDefined(); - const calledMaterial = callArgs![0]; - expect(calledMaterial.description).toBe("测试描述"); - expect(calledMaterial.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(calledMaterial.id).toBeTruthy(); + const calledBody = callArgs![0]; + expect(calledBody.description).toBe("测试描述"); + expect(calledBody.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + test("提交失败显示错误提示", async () => { + const onAdd = vi.fn<(body: CreateMaterialRequest) => Promise>(); + onAdd.mockRejectedValue(new Error("网络错误")); + renderWithProviders( + createElement(AddMaterialModal, { + onAdd, + onOpenChange: vi.fn(), + open: true, + }), + ); + + const textarea = screen.getByPlaceholderText("请输入素材描述"); + fireEvent.change(textarea, { target: { value: "测试描述" } }); + + fireEvent.click(screen.getByText("确 定")); + + await waitFor(() => { + expect(onAdd).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/tests/web/features/inbox/InboxPage.test.tsx b/tests/web/features/inbox/InboxPage.test.tsx index 06b0b6a..8ca3244 100644 --- a/tests/web/features/inbox/InboxPage.test.tsx +++ b/tests/web/features/inbox/InboxPage.test.tsx @@ -2,18 +2,85 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, test } from "bun:test"; import { createElement } from "react"; +import type { Project } from "../../../../src/shared/api"; + import { InboxPage } from "../../../../src/web/features/inbox"; -import { renderWithProviders } from "../../test-utils"; +import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-project"; +import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils"; + +const MOCK_PROJECT: Project = { + archivedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + description: "", + id: "project-1", + name: "测试项目", + status: "active", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const EMPTY_LIST = { items: [], page: 1, pageSize: 20, total: 0 }; + +function makeMaterial(overrides: Partial<{ description: string; id: string }> = {}) { + return { + associatedDate: "2026-06-03", + createdAt: "2026-06-03T00:00:00.000Z", + description: overrides.description ?? "测试素材", + id: overrides.id ?? "mat-1", + projectId: "project-1", + status: "pending", + updatedAt: "2026-06-03T00:00:00.000Z", + }; +} + +function renderInboxPage() { + return renderWithProviders( + createElement(ProjectContext.Provider, { + children: createElement(InboxPage), + value: MOCK_PROJECT, + }), + ); +} describe("InboxPage", () => { - test("初始状态显示空状态提示", () => { - renderWithProviders(createElement(InboxPage)); - expect(screen.getByText("暂无素材")).not.toBeNull(); + test("初始状态显示空列表和空详情", async () => { + const calls = installFetchMock((call) => { + if (call.url.includes("/materials")) return jsonResponse(EMPTY_LIST); + return jsonResponse({}); + }); + + renderInboxPage(); + + await waitFor(() => { + expect(screen.getByText("暂无素材")).not.toBeNull(); + }); expect(screen.getByText("请在左侧选择素材")).not.toBeNull(); + + const listCall = calls.find((c) => c.url.includes("/materials") && c.method === "GET"); + expect(listCall).toBeDefined(); }); test("新增素材后列表追加且自动选中", async () => { - renderWithProviders(createElement(InboxPage)); + const createdId = "mat-new"; + const created = makeMaterial({ description: "新增的素材", id: createdId }); + + installFetchMock((call) => { + if (call.method === "POST" && call.url.includes("/materials")) { + return jsonResponse({ material: created }, { status: 201 }); + } + if (call.method === "GET" && call.url.includes("/materials/" + createdId)) { + return jsonResponse({ material: created }); + } + if (call.method === "GET" && call.url.includes("/materials")) { + return jsonResponse({ ...EMPTY_LIST, items: [created], total: 1 }); + } + return jsonResponse({}); + }); + + renderInboxPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /新增素材/ })).not.toBeNull(); + }); fireEvent.click(screen.getByRole("button", { name: /新增素材/ })); @@ -27,27 +94,28 @@ describe("InboxPage", () => { fireEvent.click(screen.getByText("确 定")); await waitFor(() => { - expect(screen.getByText("新增的素材")).not.toBeNull(); + const cards = screen.getAllByText("新增的素材"); + expect(cards.length).toBeGreaterThanOrEqual(1); }); - - expect(screen.getByText("素材详情")).not.toBeNull(); - expect(screen.queryByText("暂无素材")).toBeNull(); - expect(screen.queryByText("请在左侧选择素材")).toBeNull(); }); test("删除素材后列表更新", async () => { - renderWithProviders(createElement(InboxPage)); + let deleted = false; + const material = makeMaterial({ description: "待删除的素材", id: "mat-del" }); - fireEvent.click(screen.getByRole("button", { name: /新增素材/ })); - - await waitFor(() => { - expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull(); + installFetchMock((call) => { + if (call.method === "DELETE" && call.url.includes("/materials/" + material.id)) { + deleted = true; + return new Response(null, { status: 204 }); + } + if (call.method === "GET" && call.url.includes("/materials")) { + if (deleted) return jsonResponse(EMPTY_LIST); + return jsonResponse({ ...EMPTY_LIST, items: [material], total: 1 }); + } + return jsonResponse({}); }); - const textarea = screen.getByPlaceholderText("请输入素材描述"); - fireEvent.change(textarea, { target: { value: "待删除的素材" } }); - - fireEvent.click(screen.getByText("确 定")); + renderInboxPage(); await waitFor(() => { expect(screen.getByText("待删除的素材")).not.toBeNull(); @@ -55,9 +123,14 @@ describe("InboxPage", () => { fireEvent.click(screen.getByLabelText("删除")); + await waitFor(() => { + expect(screen.getByText("确认删除该素材?")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("删 除")); + await waitFor(() => { expect(screen.getByText("暂无素材")).not.toBeNull(); - expect(screen.getByText("请在左侧选择素材")).not.toBeNull(); }); }); }); diff --git a/tests/web/features/inbox/MaterialCard.test.tsx b/tests/web/features/inbox/MaterialCard.test.tsx index 3b2adfd..6ac4b50 100644 --- a/tests/web/features/inbox/MaterialCard.test.tsx +++ b/tests/web/features/inbox/MaterialCard.test.tsx @@ -1,21 +1,24 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, test, vi } from "bun:test"; import { createElement } from "react"; -import type { Material } from "../../../../src/web/features/inbox/types"; +import type { Material } from "../../../../src/shared/api"; import { MaterialCard } from "../../../../src/web/features/inbox/components/MaterialCard"; import { renderWithProviders } from "../../test-utils"; const MOCK_MATERIAL: Material = { associatedDate: "2026-06-03", - createdAt: new Date().toISOString(), + createdAt: "2026-06-03T00:00:00.000Z", description: "测试素材描述", id: "test-id", + projectId: "project-1", + status: "pending", + updatedAt: "2026-06-03T00:00:00.000Z", }; describe("MaterialCard", () => { - test("渲染素材描述和日期信息", () => { + test("渲染素材描述和创建时间", () => { renderWithProviders( createElement(MaterialCard, { material: MOCK_MATERIAL, @@ -25,7 +28,7 @@ describe("MaterialCard", () => { }), ); expect(screen.getByText("测试素材描述")).not.toBeNull(); - expect(screen.getByText(/2026-06-03/)).not.toBeNull(); + expect(screen.getByText("今天")).not.toBeNull(); }); test("点击卡片触发 onSelect", () => { @@ -43,7 +46,7 @@ describe("MaterialCard", () => { expect(onSelect).toHaveBeenCalledTimes(1); }); - test("点击删除按钮触发 onDelete 且不触发 onSelect", () => { + test("点击删除按钮弹出确认框,确认后触发 onDelete", async () => { const onDelete = vi.fn(); const onSelect = vi.fn(); renderWithProviders( @@ -55,11 +58,20 @@ describe("MaterialCard", () => { }), ); fireEvent.click(screen.getByLabelText("删除")); - expect(onDelete).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText("确认删除该素材?")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("删 除")); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalledTimes(1); + }); expect(onSelect).not.toHaveBeenCalled(); }); - test("选中状态添加 app-inbox-card-selected 类", () => { + test("选中状态不再使用 app-inbox-card-selected 类", () => { renderWithProviders( createElement(MaterialCard, { material: MOCK_MATERIAL, @@ -69,6 +81,6 @@ describe("MaterialCard", () => { }), ); const card = screen.getByText("测试素材描述").closest(".app-inbox-card-selected"); - expect(card).not.toBeNull(); + expect(card).toBeNull(); }); }); diff --git a/tests/web/features/inbox/MaterialContent.test.tsx b/tests/web/features/inbox/MaterialContent.test.tsx index 8e4f1fc..1a1c48b 100644 --- a/tests/web/features/inbox/MaterialContent.test.tsx +++ b/tests/web/features/inbox/MaterialContent.test.tsx @@ -2,28 +2,39 @@ import { screen } from "@testing-library/react"; import { describe, expect, test } from "bun:test"; import { createElement } from "react"; -import type { Material } from "../../../../src/web/features/inbox/types"; +import type { Material } from "../../../../src/shared/api"; import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent"; import { renderWithProviders } from "../../test-utils"; const MOCK_MATERIAL: Material = { associatedDate: "2026-06-03", - createdAt: new Date().toISOString(), + createdAt: "2026-06-03T00:00:00.000Z", description: "详细描述内容", id: "test-id", + projectId: "project-1", + status: "pending", + updatedAt: "2026-06-03T00:00:00.000Z", }; describe("MaterialContent", () => { - test("未选中时显示空状态提示", () => { - renderWithProviders(createElement(MaterialContent, { material: null })); - expect(screen.getByText("请在左侧选择素材")).not.toBeNull(); - }); - - test("选中时展示素材详情", () => { + test("展示素材详情和状态", () => { renderWithProviders(createElement(MaterialContent, { material: MOCK_MATERIAL })); expect(screen.getByText("素材详情")).not.toBeNull(); expect(screen.getByText("详细描述内容")).not.toBeNull(); expect(screen.getByText("2026-06-03")).not.toBeNull(); + expect(screen.getByText("待审核")).not.toBeNull(); + }); + + test("展示已通过状态", () => { + const approved: Material = { ...MOCK_MATERIAL, status: "approved" }; + renderWithProviders(createElement(MaterialContent, { material: approved })); + expect(screen.getByText("已通过")).not.toBeNull(); + }); + + test("展示已放弃状态", () => { + const discarded: Material = { ...MOCK_MATERIAL, status: "discarded" }; + renderWithProviders(createElement(MaterialContent, { material: discarded })); + expect(screen.getByText("已放弃")).not.toBeNull(); }); }); diff --git a/tests/web/features/inbox/MaterialList.test.tsx b/tests/web/features/inbox/MaterialList.test.tsx index b07eaf6..bda5d91 100644 --- a/tests/web/features/inbox/MaterialList.test.tsx +++ b/tests/web/features/inbox/MaterialList.test.tsx @@ -2,7 +2,7 @@ import { screen } from "@testing-library/react"; import { describe, expect, test, vi } from "bun:test"; import { createElement } from "react"; -import type { Material } from "../../../../src/web/features/inbox/types"; +import type { Material } from "../../../../src/shared/api"; import { MaterialList } from "../../../../src/web/features/inbox/components/MaterialList"; import { renderWithProviders } from "../../test-utils"; @@ -10,15 +10,21 @@ import { renderWithProviders } from "../../test-utils"; const MOCK_MATERIALS: Material[] = [ { associatedDate: "2026-06-03", - createdAt: new Date().toISOString(), + createdAt: "2026-06-03T00:00:00.000Z", description: "素材一", id: "id-1", + projectId: "project-1", + status: "pending", + updatedAt: "2026-06-03T00:00:00.000Z", }, { associatedDate: "2026-06-02", - createdAt: new Date().toISOString(), + createdAt: "2026-06-02T00:00:00.000Z", description: "素材二", id: "id-2", + projectId: "project-1", + status: "pending", + updatedAt: "2026-06-02T00:00:00.000Z", }, ]; @@ -26,6 +32,7 @@ describe("MaterialList", () => { test("列表为空时显示暂无素材", () => { renderWithProviders( createElement(MaterialList, { + loading: false, materials: [], onAddClick: vi.fn(), onDelete: vi.fn(), @@ -39,6 +46,7 @@ describe("MaterialList", () => { test("渲染素材卡片列表", () => { renderWithProviders( createElement(MaterialList, { + loading: false, materials: MOCK_MATERIALS, onAddClick: vi.fn(), onDelete: vi.fn(), @@ -54,6 +62,7 @@ describe("MaterialList", () => { const onAddClick = vi.fn(); renderWithProviders( createElement(MaterialList, { + loading: false, materials: [], onAddClick, onDelete: vi.fn(), @@ -64,4 +73,18 @@ describe("MaterialList", () => { screen.getByText("新增素材").click(); expect(onAddClick).toHaveBeenCalledTimes(1); }); + + test("加载中显示 Spin", () => { + renderWithProviders( + createElement(MaterialList, { + loading: true, + materials: [], + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + expect(document.querySelector(".ant-spin")).not.toBeNull(); + }); });