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

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