From 2cdbe474ce28483d5ed77da3a623988fcb33bf6b Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 3 Jun 2026 08:36:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20=E6=96=B0=E5=A2=9E=E6=94=B6?= =?UTF-8?q?=E9=9B=86=E7=AE=B1=E9=A1=B5=E9=9D=A2=20=E2=80=94=20=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=88=97=E8=A1=A8/=E8=AF=A6=E6=83=85=E5=88=86?= =?UTF-8?q?=E6=A0=8F=E5=B8=83=E5=B1=80=20+=20=E6=96=B0=E5=A2=9E/=E9=80=89?= =?UTF-8?q?=E4=B8=AD/=E5=88=A0=E9=99=A4=20mock=20=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbox/components/AddMaterialModal.tsx | 67 ++++++++++++++++ .../inbox/components/MaterialCard.tsx | 37 +++++++++ .../inbox/components/MaterialContent.tsx | 32 ++++++++ .../inbox/components/MaterialList.tsx | 39 ++++++++++ src/web/features/inbox/index.tsx | 39 ++++++++++ src/web/features/inbox/types.ts | 6 ++ src/web/layouts/workbench-layout/routes.ts | 3 +- src/web/routes.tsx | 2 + src/web/styles.css | 43 +++++++++++ .../features/inbox/AddMaterialModal.test.tsx | 77 +++++++++++++++++++ tests/web/features/inbox/InboxPage.test.tsx | 63 +++++++++++++++ .../web/features/inbox/MaterialCard.test.tsx | 74 ++++++++++++++++++ .../features/inbox/MaterialContent.test.tsx | 29 +++++++ .../web/features/inbox/MaterialList.test.tsx | 67 ++++++++++++++++ tests/web/routes/workbench.test.tsx | 30 ++++++++ 15 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 src/web/features/inbox/components/AddMaterialModal.tsx create mode 100644 src/web/features/inbox/components/MaterialCard.tsx create mode 100644 src/web/features/inbox/components/MaterialContent.tsx create mode 100644 src/web/features/inbox/components/MaterialList.tsx create mode 100644 src/web/features/inbox/index.tsx create mode 100644 src/web/features/inbox/types.ts create mode 100644 tests/web/features/inbox/AddMaterialModal.test.tsx create mode 100644 tests/web/features/inbox/InboxPage.test.tsx create mode 100644 tests/web/features/inbox/MaterialCard.test.tsx create mode 100644 tests/web/features/inbox/MaterialContent.test.tsx create mode 100644 tests/web/features/inbox/MaterialList.test.tsx diff --git a/src/web/features/inbox/components/AddMaterialModal.tsx b/src/web/features/inbox/components/AddMaterialModal.tsx new file mode 100644 index 0000000..da10cf1 --- /dev/null +++ b/src/web/features/inbox/components/AddMaterialModal.tsx @@ -0,0 +1,67 @@ +import { App as AntApp, DatePicker, Form, Input, Modal } from "antd"; +import dayjs from "dayjs"; +import { useEffect } from "react"; + +import type { Material } from "../types"; + +interface AddMaterialModalProps { + onAdd: (material: Material) => void; + onOpenChange: (open: boolean) => void; + open: boolean; +} + +interface FormValues { + associatedDate: dayjs.Dayjs; + description: string; +} + +export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) { + const { message } = AntApp.useApp(); + const [form] = Form.useForm(); + + useEffect(() => { + if (!open) return; + form.resetFields(); + }, [form, open]); + + const handleFinish = (values: FormValues) => { + const material: Material = { + associatedDate: values.associatedDate.format("YYYY-MM-DD"), + createdAt: new Date().toISOString(), + description: values.description, + id: crypto.randomUUID(), + }; + onAdd(material); + message.success("素材已添加"); + onOpenChange(false); + }; + + return ( + onOpenChange(false)} + onOk={() => void form.submit()} + open={open} + title="新增素材" + > +
void handleFinish(values)} + > + + + + + + +
+
+ ); +} diff --git a/src/web/features/inbox/components/MaterialCard.tsx b/src/web/features/inbox/components/MaterialCard.tsx new file mode 100644 index 0000000..61adc12 --- /dev/null +++ b/src/web/features/inbox/components/MaterialCard.tsx @@ -0,0 +1,37 @@ +import { DeleteOutlined } from "@ant-design/icons"; +import { Button, Card, Flex, Typography } from "antd"; + +import type { Material } from "../types"; + +import { formatRelativeTime } from "../../../shared/utils/time"; + +interface MaterialCardProps { + material: Material; + onDelete: () => void; + onSelect: () => void; + selected: boolean; +} + +export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) { + return ( + + {material.description} + + + {material.associatedDate} · {formatRelativeTime(material.createdAt)} + + +
+ {materials.length === 0 ? ( + + ) : ( + materials.map((material) => ( + onDelete(material.id)} + onSelect={() => onSelect(material.id)} + selected={material.id === selectedId} + /> + )) + )} +
+ + ); +} diff --git a/src/web/features/inbox/index.tsx b/src/web/features/inbox/index.tsx new file mode 100644 index 0000000..5b867c5 --- /dev/null +++ b/src/web/features/inbox/index.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; + +import type { Material } from "./types"; + +import { AddMaterialModal } from "./components/AddMaterialModal"; +import { MaterialContent } from "./components/MaterialContent"; +import { MaterialList } from "./components/MaterialList"; + +export function InboxPage() { + const [materials, setMaterials] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + const selectedMaterial = materials.find((m) => m.id === selectedId) ?? null; + + const handleAdd = (material: Material) => { + setMaterials((prev) => [...prev, material].sort((a, b) => b.associatedDate.localeCompare(a.associatedDate))); + setSelectedId(material.id); + }; + + const handleDelete = (id: string) => { + setMaterials((prev) => prev.filter((m) => m.id !== id)); + if (selectedId === id) setSelectedId(null); + }; + + return ( +
+ setModalOpen(true)} + onDelete={handleDelete} + onSelect={setSelectedId} + selectedId={selectedId} + /> + + +
+ ); +} diff --git a/src/web/features/inbox/types.ts b/src/web/features/inbox/types.ts new file mode 100644 index 0000000..af60d30 --- /dev/null +++ b/src/web/features/inbox/types.ts @@ -0,0 +1,6 @@ +export interface Material { + associatedDate: string; + createdAt: string; + description: string; + id: string; +} diff --git a/src/web/layouts/workbench-layout/routes.ts b/src/web/layouts/workbench-layout/routes.ts index 9ddb5fb..bf0b6e0 100644 --- a/src/web/layouts/workbench-layout/routes.ts +++ b/src/web/layouts/workbench-layout/routes.ts @@ -1,10 +1,11 @@ -import { MessageOutlined } from "@ant-design/icons"; +import { InboxOutlined, MessageOutlined } from "@ant-design/icons"; import { createElement } from "react"; import type { MenuItemConfig } from "../../menu"; export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [ { icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" }, + { icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" }, ] as const; export function buildWorkbenchPath(projectId: string, relativePath = ""): string { diff --git a/src/web/routes.tsx b/src/web/routes.tsx index b8a22e2..ba0df08 100644 --- a/src/web/routes.tsx +++ b/src/web/routes.tsx @@ -2,6 +2,7 @@ import { Route, Routes } from "react-router"; import { ChatPage } from "./features/chat/ChatPage"; import { DashboardPage } from "./features/dashboard"; +import { InboxPage } from "./features/inbox"; import { ModelsPage } from "./features/models"; import { NotFoundPage } from "./features/not-found"; import { ProjectsPage } from "./features/projects"; @@ -19,6 +20,7 @@ export function AppRoutes() { } path="/workbench/:projectId"> } path="" /> } path="chat" /> + } path="inbox" /> } path="*" /> diff --git a/src/web/styles.css b/src/web/styles.css index f179f20..14e5d02 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -264,3 +264,46 @@ body { .x-markdown-dark .x-md-table-wrap { overflow-x: auto; } + +.app-inbox-page { + display: flex; + height: 100%; + overflow: hidden; +} + +.app-inbox-sidebar { + display: flex; + width: 280px; + flex-direction: column; + gap: var(--ant-margin-sm); + padding: var(--ant-padding-sm); + border-right: 1px solid var(--ant-color-border-secondary); + background: var(--ant-color-bg-container); +} + +.app-inbox-list { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--ant-margin-xs); + min-height: 0; + overflow-y: auto; +} + +.app-inbox-content { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + min-width: 0; + padding: var(--ant-padding-xl); + overflow-y: auto; +} + +.app-inbox-card-selected { + box-shadow: 0 0 0 1px var(--ant-color-primary); +} + +.app-inbox-datepicker { + width: 100%; +} diff --git a/tests/web/features/inbox/AddMaterialModal.test.tsx b/tests/web/features/inbox/AddMaterialModal.test.tsx new file mode 100644 index 0000000..2331f5c --- /dev/null +++ b/tests/web/features/inbox/AddMaterialModal.test.tsx @@ -0,0 +1,77 @@ +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 { AddMaterialModal } from "../../../../src/web/features/inbox/components/AddMaterialModal"; +import { renderWithProviders } from "../../test-utils"; + +describe("AddMaterialModal", () => { + test("打开时渲染表单字段", () => { + renderWithProviders( + createElement(AddMaterialModal, { + onAdd: vi.fn(), + onOpenChange: vi.fn(), + open: true, + }), + ); + expect(screen.getByText("新增素材")).not.toBeNull(); + expect(screen.getByText("描述")).not.toBeNull(); + expect(screen.getByText("关联时间")).not.toBeNull(); + }); + + test("关闭时不渲染", () => { + renderWithProviders( + createElement(AddMaterialModal, { + onAdd: vi.fn(), + onOpenChange: vi.fn(), + open: false, + }), + ); + expect(screen.queryByText("新增素材")).toBeNull(); + }); + + test("提交空表单显示必填校验", async () => { + renderWithProviders( + createElement(AddMaterialModal, { + onAdd: vi.fn(), + onOpenChange: vi.fn(), + open: true, + }), + ); + + fireEvent.click(screen.getByText("确 定")); + + await waitFor(() => { + expect(screen.getByText("请输入描述")).not.toBeNull(); + }); + }); + + test("点击确定触发表单提交", async () => { + const onAdd = vi.fn<(material: Material) => void>(); + 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); + }); + + 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(); + }); +}); diff --git a/tests/web/features/inbox/InboxPage.test.tsx b/tests/web/features/inbox/InboxPage.test.tsx new file mode 100644 index 0000000..06b0b6a --- /dev/null +++ b/tests/web/features/inbox/InboxPage.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; + +import { InboxPage } from "../../../../src/web/features/inbox"; +import { renderWithProviders } from "../../test-utils"; + +describe("InboxPage", () => { + test("初始状态显示空状态提示", () => { + renderWithProviders(createElement(InboxPage)); + expect(screen.getByText("暂无素材")).not.toBeNull(); + expect(screen.getByText("请在左侧选择素材")).not.toBeNull(); + }); + + test("新增素材后列表追加且自动选中", async () => { + renderWithProviders(createElement(InboxPage)); + + fireEvent.click(screen.getByRole("button", { name: /新增素材/ })); + + await waitFor(() => { + expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull(); + }); + + const textarea = screen.getByPlaceholderText("请输入素材描述"); + fireEvent.change(textarea, { target: { value: "新增的素材" } }); + + fireEvent.click(screen.getByText("确 定")); + + await waitFor(() => { + expect(screen.getByText("新增的素材")).not.toBeNull(); + }); + + expect(screen.getByText("素材详情")).not.toBeNull(); + expect(screen.queryByText("暂无素材")).toBeNull(); + expect(screen.queryByText("请在左侧选择素材")).toBeNull(); + }); + + test("删除素材后列表更新", async () => { + renderWithProviders(createElement(InboxPage)); + + fireEvent.click(screen.getByRole("button", { name: /新增素材/ })); + + await waitFor(() => { + expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull(); + }); + + const textarea = screen.getByPlaceholderText("请输入素材描述"); + fireEvent.change(textarea, { target: { value: "待删除的素材" } }); + + fireEvent.click(screen.getByText("确 定")); + + await waitFor(() => { + expect(screen.getByText("待删除的素材")).not.toBeNull(); + }); + + fireEvent.click(screen.getByLabelText("删除")); + + await waitFor(() => { + expect(screen.getByText("暂无素材")).not.toBeNull(); + expect(screen.getByText("请在左侧选择素材")).not.toBeNull(); + }); + }); +}); diff --git a/tests/web/features/inbox/MaterialCard.test.tsx b/tests/web/features/inbox/MaterialCard.test.tsx new file mode 100644 index 0000000..3b2adfd --- /dev/null +++ b/tests/web/features/inbox/MaterialCard.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, 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 { 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(), + description: "测试素材描述", + id: "test-id", +}; + +describe("MaterialCard", () => { + test("渲染素材描述和日期信息", () => { + renderWithProviders( + createElement(MaterialCard, { + material: MOCK_MATERIAL, + onDelete: vi.fn(), + onSelect: vi.fn(), + selected: false, + }), + ); + expect(screen.getByText("测试素材描述")).not.toBeNull(); + expect(screen.getByText(/2026-06-03/)).not.toBeNull(); + }); + + test("点击卡片触发 onSelect", () => { + const onSelect = vi.fn(); + renderWithProviders( + createElement(MaterialCard, { + material: MOCK_MATERIAL, + onDelete: vi.fn(), + onSelect, + selected: false, + }), + ); + const card = screen.getByText("测试素材描述").closest(".ant-card")!; + fireEvent.click(card); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test("点击删除按钮触发 onDelete 且不触发 onSelect", () => { + const onDelete = vi.fn(); + const onSelect = vi.fn(); + renderWithProviders( + createElement(MaterialCard, { + material: MOCK_MATERIAL, + onDelete, + onSelect, + selected: false, + }), + ); + fireEvent.click(screen.getByLabelText("删除")); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test("选中状态添加 app-inbox-card-selected 类", () => { + renderWithProviders( + createElement(MaterialCard, { + material: MOCK_MATERIAL, + onDelete: vi.fn(), + onSelect: vi.fn(), + selected: true, + }), + ); + const card = screen.getByText("测试素材描述").closest(".app-inbox-card-selected"); + expect(card).not.toBeNull(); + }); +}); diff --git a/tests/web/features/inbox/MaterialContent.test.tsx b/tests/web/features/inbox/MaterialContent.test.tsx new file mode 100644 index 0000000..8e4f1fc --- /dev/null +++ b/tests/web/features/inbox/MaterialContent.test.tsx @@ -0,0 +1,29 @@ +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 { 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(), + description: "详细描述内容", + id: "test-id", +}; + +describe("MaterialContent", () => { + test("未选中时显示空状态提示", () => { + renderWithProviders(createElement(MaterialContent, { material: null })); + expect(screen.getByText("请在左侧选择素材")).not.toBeNull(); + }); + + 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(); + }); +}); diff --git a/tests/web/features/inbox/MaterialList.test.tsx b/tests/web/features/inbox/MaterialList.test.tsx new file mode 100644 index 0000000..b07eaf6 --- /dev/null +++ b/tests/web/features/inbox/MaterialList.test.tsx @@ -0,0 +1,67 @@ +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 { MaterialList } from "../../../../src/web/features/inbox/components/MaterialList"; +import { renderWithProviders } from "../../test-utils"; + +const MOCK_MATERIALS: Material[] = [ + { + associatedDate: "2026-06-03", + createdAt: new Date().toISOString(), + description: "素材一", + id: "id-1", + }, + { + associatedDate: "2026-06-02", + createdAt: new Date().toISOString(), + description: "素材二", + id: "id-2", + }, +]; + +describe("MaterialList", () => { + test("列表为空时显示暂无素材", () => { + renderWithProviders( + createElement(MaterialList, { + materials: [], + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + expect(screen.getByText("暂无素材")).not.toBeNull(); + }); + + test("渲染素材卡片列表", () => { + renderWithProviders( + createElement(MaterialList, { + materials: MOCK_MATERIALS, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + expect(screen.getByText("素材一")).not.toBeNull(); + expect(screen.getByText("素材二")).not.toBeNull(); + }); + + test("点击新增素材按钮触发 onAddClick", () => { + const onAddClick = vi.fn(); + renderWithProviders( + createElement(MaterialList, { + materials: [], + onAddClick, + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + screen.getByText("新增素材").click(); + expect(onAddClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/web/routes/workbench.test.tsx b/tests/web/routes/workbench.test.tsx index f979894..6810d3d 100644 --- a/tests/web/routes/workbench.test.tsx +++ b/tests/web/routes/workbench.test.tsx @@ -120,4 +120,34 @@ describe("Workbench 路由", () => { { timeout: 10000 }, ); }); + + test("Workbench 收集箱路由可达", async () => { + createMockHandler(); + + renderWithProviders(createElement(App), { + initialRoute: `/workbench/${MOCK_PROJECT.id}/inbox`, + }); + + await waitFor( + () => { + expect(screen.getByText("新增素材")).not.toBeNull(); + }, + { timeout: 10000 }, + ); + }); + + test("Workbench 显示收集箱菜单", async () => { + createMockHandler(); + + renderWithProviders(createElement(App), { + initialRoute: `/workbench/${MOCK_PROJECT.id}`, + }); + + await waitFor( + () => { + expect(screen.getByText("收集箱")).not.toBeNull(); + }, + { timeout: 10000 }, + ); + }); });