feat(inbox): 素材持久化 CRUD — 数据库表 + API + 前端接入
- 新增 materials 表(id/projectId/description/associatedDate/status/createdAt/updatedAt) - 新增 4 个后端 API 路由(list/create/get/delete)+ 13 个测试 - 新增 use-materials hooks(TanStack Query) - 收集箱页面重构为三层架构(InboxPage + MaterialSidebar + MaterialDetailPanel) - MaterialCard: Popconfirm 删除确认 + 粗粒度时间格式 - MaterialContent: 展示状态标签 + createdAt - 更新开发文档 backend.md / frontend.md
This commit is contained in:
107
src/server/db/materials.ts
Normal file
107
src/server/db/materials.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { materials, projects } from "./schema";
|
||||
|
||||
export function createMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
request: CreateMaterialRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const project = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!project) return { error: "项目不存在", status: 404 };
|
||||
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
||||
|
||||
const description = request.description.trim();
|
||||
if (!description) return { error: "描述不能为空", status: 400 };
|
||||
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(request.associatedDate)) {
|
||||
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.insert(materials)
|
||||
.values({
|
||||
associatedDate: request.associatedDate,
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
projectId,
|
||||
status: "pending",
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(materials).where(eq(materials.id, id)).get();
|
||||
return { material: toMaterial(row!) };
|
||||
}
|
||||
|
||||
export function deleteMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
|
||||
db.delete(materials).where(eq(materials.id, materialId)).run();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
|
||||
return { material: toMaterial(row) };
|
||||
}
|
||||
|
||||
export function listMaterials(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
options: { page: number; pageSize: number; status?: MaterialStatus },
|
||||
): { items: Material[]; page: number; pageSize: number; total: number } {
|
||||
const conditions = [eq(materials.projectId, projectId)];
|
||||
|
||||
if (options.status) {
|
||||
conditions.push(eq(materials.status, options.status));
|
||||
}
|
||||
|
||||
return paginateQuery(raw, materials, {
|
||||
conditions,
|
||||
mapRow: toMaterial,
|
||||
orderBy: () => desc(materials.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
function toMaterial(row: typeof materials.$inferSelect): Material {
|
||||
return {
|
||||
associatedDate: row.associatedDate,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -62,6 +62,24 @@ export const conversations = sqliteTable(
|
||||
(table) => [index("conversations_project_id_idx").on(table.projectId)],
|
||||
);
|
||||
|
||||
export const materials = sqliteTable(
|
||||
"materials",
|
||||
{
|
||||
associatedDate: text("associated_date").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
description: text("description").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [index("materials_project_id_idx").on(table.projectId)],
|
||||
);
|
||||
|
||||
export const messages = sqliteTable(
|
||||
"messages",
|
||||
{
|
||||
|
||||
45
src/server/routes/materials/create.ts
Normal file
45
src/server/routes/materials/create.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { CreateMaterialRequest, RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { createMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleCreateMaterial(
|
||||
req: Request,
|
||||
db: Database,
|
||||
mode: RuntimeMode,
|
||||
logger: Logger,
|
||||
): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const projectIdStr = parseIdFromUrl(url);
|
||||
|
||||
const validated = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: CreateMaterialRequest;
|
||||
try {
|
||||
body = (await req.json()) as CreateMaterialRequest;
|
||||
} catch (e: unknown) {
|
||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.description || typeof body.description !== "string") {
|
||||
return jsonResponse(createApiError("description is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.associatedDate || typeof body.associatedDate !== "string") {
|
||||
return jsonResponse(createApiError("associatedDate is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = createMaterial(db, validated.id, body, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: result.material.id, projectId: validated.id }, "素材创建成功");
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
29
src/server/routes/materials/delete.ts
Normal file
29
src/server/routes/materials/delete.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { deleteMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDeleteMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/");
|
||||
const projectIdStr = parts[3];
|
||||
const materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = deleteMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材删除成功");
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
28
src/server/routes/materials/get.ts
Normal file
28
src/server/routes/materials/get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { getMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleGetMaterial(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/");
|
||||
const projectIdStr = parts[3];
|
||||
const materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = getMaterial(db, validatedProject.id, validatedMaterial.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
35
src/server/routes/materials/list.ts
Normal file
35
src/server/routes/materials/list.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { listMaterials } from "../../db/materials";
|
||||
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||
import { validateIdParam, validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListMaterials(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const projectIdStr = parseIdFromUrl(url);
|
||||
|
||||
const validated = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const statusParam = url.searchParams.get("status");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
if (statusParam && statusParam !== "pending" && statusParam !== "approved" && statusParam !== "discarded") {
|
||||
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = listMaterials(db, validated.id, {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
status: (statusParam as "approved" | "discarded" | "pending") ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
@@ -220,6 +220,42 @@ export function startServer(options: StartServerOptions) {
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials": {
|
||||
GET: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleListMaterials } = await import("./routes/materials/list");
|
||||
return handleListMaterials(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleCreateMaterial } = await import("./routes/materials/create");
|
||||
return handleCreateMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials/:mid": {
|
||||
DELETE: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleDeleteMaterial } = await import("./routes/materials/delete");
|
||||
return handleDeleteMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
GET: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleGetMaterial } = await import("./routes/materials/get");
|
||||
return handleGetMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/restore": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
51
src/web/features/inbox/components/MaterialDetailPanel.tsx
Normal file
51
src/web/features/inbox/components/MaterialDetailPanel.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
44
src/web/features/inbox/components/MaterialSidebar.tsx
Normal file
44
src/web/features/inbox/components/MaterialSidebar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export interface Material {
|
||||
associatedDate: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
}
|
||||
export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api";
|
||||
|
||||
95
src/web/shared/hooks/use-materials.ts
Normal file
95
src/web/shared/hooks/use-materials.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user