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

@@ -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
| 方法 | 路径 | 说明 |

View File

@@ -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` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享工具函数

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -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
}
]
}

107
src/server/db/materials.ts Normal file
View 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,
};
}

View File

@@ -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",
{

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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<Material>;
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<FormValues>();
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 (
<Modal
confirmLoading={submitting}
destroyOnHidden
okText="确定"
onCancel={() => onOpenChange(false)}

View File

@@ -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 (
<Card className={selected ? "app-inbox-card-selected" : undefined} hoverable onClick={onSelect} size="small">
<Card hoverable={false} onClick={onSelect} size="small">
<Typography.Paragraph ellipsis={{ rows: 3 }}>{material.description}</Typography.Paragraph>
<Flex align="center" justify="space-between">
<Typography.Text type="secondary">
{material.associatedDate} · {formatRelativeTime(material.createdAt)}
</Typography.Text>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
<Typography.Text type="secondary">{formatMaterialTime(material.createdAt)}</Typography.Text>
<Popconfirm
description="删除后不可恢复"
okButtonProps={{ danger: true }}
okText="删除"
onCancel={(e) => e?.stopPropagation()}
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
size="small"
type="text"
/>
title="确认删除该素材?"
>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
</Flex>
</Card>
);
}
function formatMaterialTime(timestamp: string, now = new Date()): string {
const time = new Date(timestamp);
const diffMs = now.getTime() - time.getTime();
if (diffMs < 60_000) return "刚刚";
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86_400_000);
const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate());
if (timeDate.getTime() >= today.getTime()) return "今天";
if (timeDate.getTime() >= yesterday.getTime()) return "昨天";
const mm = String(time.getMonth() + 1).padStart(2, "0");
const dd = String(time.getDate()).padStart(2, "0");
return `${time.getFullYear()}-${mm}-${dd}`;
}

View File

@@ -1,21 +1,21 @@
import { Card, Descriptions, Empty, Typography } from "antd";
import { Card, Descriptions, Tag, Typography } from "antd";
import type { Material } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
interface MaterialContentProps {
material: Material | null;
material: Material;
}
const STATUS_MAP: Record<string, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" },
pending: { color: "gold", label: "待审核" },
};
export function MaterialContent({ material }: MaterialContentProps) {
if (!material) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
return (
<div className="app-inbox-content">
@@ -23,6 +23,9 @@ export function MaterialContent({ material }: MaterialContentProps) {
<Card>
<Typography.Paragraph>{material.description}</Typography.Paragraph>
<Descriptions column={1} size="small">
<Descriptions.Item label="状态">
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
</Descriptions>

View File

@@ -0,0 +1,51 @@
import { Empty, Result, Spin } from "antd";
import { useMaterial } from "../../../shared/hooks/use-materials";
import { MaterialContent } from "./MaterialContent";
interface MaterialDetailPanelProps {
materialId: null | string;
projectId: string;
}
export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPanelProps) {
if (!materialId) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
return <MaterialDetailPanelInner materialId={materialId} projectId={projectId} />;
}
function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanelProps) {
const { data, error, isLoading } = useMaterial({ materialId, projectId });
if (isLoading) {
return (
<div className="app-inbox-content">
<Spin />
</div>
);
}
if (error) {
return (
<div className="app-inbox-content">
<Result subTitle="加载素材详情失败" />
</div>
);
}
if (!data) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
return <MaterialContent material={data} />;
}

View File

@@ -1,11 +1,12 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty } from "antd";
import { Button, Empty, Spin } from "antd";
import type { Material } from "../types";
import { MaterialCard } from "./MaterialCard";
interface MaterialListProps {
loading: boolean;
materials: readonly Material[];
onAddClick: () => void;
onDelete: (id: string) => void;
@@ -13,14 +14,16 @@ interface MaterialListProps {
selectedId: null | string;
}
export function MaterialList({ materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
return (
<div className="app-inbox-sidebar">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<div className="app-inbox-list">
{materials.length === 0 ? (
{loading ? (
<Spin />
) : materials.length === 0 ? (
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
materials.map((material) => (

View File

@@ -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 (
<div className="app-inbox-sidebar">
<Result
extra={<button onClick={() => void refetch()}></button>}
status="error"
subTitle="加载素材列表失败"
/>
</div>
);
}
return (
<MaterialList
loading={isLoading}
materials={data?.items ?? []}
onAddClick={onAddClick}
onDelete={handleDelete}
onSelect={onSelect}
selectedId={selectedId}
/>
);
}

View File

@@ -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<Material[]>([]);
const project = useCurrentProject();
const [modalOpen, setModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<null | string>(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<Material> => {
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 (
<div className="app-inbox-page">
<MaterialList
materials={materials}
<MaterialSidebar
onAddClick={() => setModalOpen(true)}
onDelete={handleDelete}
onSelect={setSelectedId}
projectId={project.id}
selectedId={selectedId}
/>
<MaterialContent material={selectedMaterial} />
<AddMaterialModal onAdd={handleAdd} onOpenChange={setModalOpen} open={modalOpen} />
<MaterialDetailPanel materialId={selectedId} projectId={project.id} />
<AddMaterialModal onAdd={handleCreate} onOpenChange={setModalOpen} open={modalOpen} />
</div>
);
}

View File

@@ -1,6 +1 @@
export interface Material {
associatedDate: string;
createdAt: string;
description: string;
id: string;
}
export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api";

View File

@@ -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<Material> {
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<void> {
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<Material> {
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<MaterialListResponse> {
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<MaterialListResponse>;
});
}
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],
});
}

View File

@@ -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%;
}

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);
});
});
});
});

View File

@@ -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<Material>>();
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<Material>>();
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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});