diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 2a0d104..2aa7377 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -36,9 +36,9 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si ### 聊天页面 -`ChatPage` = `Conversations`(@ant-design/x)+ `ChatPanel`。 +`ChatPage` = `ConversationSidebar`(自定义组件)+ `ChatPanel`。 -- **Conversations**:会话侧边栏,TanStack Query 管理会话列表,支持创建/选中/删除(menu dropdown)。 +- **ConversationSidebar**:会话侧边栏数据加载层(useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除(Popconfirm)。 - **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。 - **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。 @@ -46,11 +46,12 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si ### 共享组件 -| 组件 | 路径 | 说明 | -| ------------- | ------------------------------------- | --------------------------------- | -| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) | -| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 | -| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 | +| 组件 | 路径 | 说明 | +| ------------- | ------------------------------------- | ------------------------------------ | +| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) | +| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 | +| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) | +| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 | ### 共享 Hooks @@ -70,10 +71,11 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si ### 共享工具函数 -| 文件 | 导出 | -| --------------- | --------------------------------------------------------------------------------------------- | -| `utils/api.ts` | `handleResponse(response, extract)`、`handleVoidResponse(response)` | -| `utils/time.ts` | `formatCountdown`、`formatDurationUnit`、`formatRelativeTime`、`isOlderThan`、`subtractHours` | +| 文件 | 导出 | +| --------------------- | --------------------------------------------------------------------------------------------- | +| `utils/api.ts` | `handleResponse(response, extract)`、`handleVoidResponse(response)` | +| `utils/time.ts` | `formatCountdown`、`formatDurationUnit`、`formatRelativeTime`、`isOlderThan`、`subtractHours` | +| `utils/date-group.ts` | `getDateGroup`、`groupByDate`、`GROUP_LABELS`、`GROUP_ORDER`、`DateGroup`、`DateGroupData` | ## 更新触发条件 diff --git a/src/web/features/chat/ChatPage.tsx b/src/web/features/chat/ChatPage.tsx index 11ee400..071d342 100644 --- a/src/web/features/chat/ChatPage.tsx +++ b/src/web/features/chat/ChatPage.tsx @@ -1,15 +1,12 @@ -import { DeleteOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons"; -import { Conversations } from "@ant-design/x"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { App, Button, Spin } from "antd"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { App } from "antd"; import { useMemo, useState } from "react"; -import type { Conversation } from "../../../shared/api"; - -import { createConversation, deleteConversation, fetchConversations } from "../../shared/hooks/use-conversations"; +import { createConversation, deleteConversation } from "../../shared/hooks/use-conversations"; import { useCurrentProject } from "../../shared/hooks/use-current-project"; import { useModelList } from "../../shared/hooks/use-models"; import { ChatPanel } from "./ChatPanel"; +import { ConversationSidebar } from "./components/ConversationSidebar"; export function ChatPage() { const project = useCurrentProject(); @@ -19,11 +16,6 @@ export function ChatPage() { const CONVERSATIONS_KEY = ["conversations", project.id] as const; - const { data, isLoading } = useQuery({ - queryFn: () => fetchConversations(project.id), - queryKey: CONVERSATIONS_KEY, - }); - const { data: modelsData } = useModelList({ pageSize: 200 }); const textModels = useMemo( @@ -44,58 +36,26 @@ export function ChatPage() { }, }); - const conversations = useMemo( - () => (data?.items ?? []).map((c: Conversation) => ({ key: c.id, label: c.title })), - [data], - ); + const handleAddConversation = () => { + void createConversation(project.id, defaultModelId ?? undefined) + .then((conv) => { + void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY }); + setActiveConversationId(conv.id); + }) + .catch((err: Error) => { + void message.error(`创建会话失败:${err.message}`); + }); + }; return (
-
-
- -
- {isLoading ? ( - - ) : ( - ({ - items: [ - { - danger: true, - icon: , - key: "delete", - label: "删除", - onClick: () => { - deleteMutation.mutate(conv.key); - }, - }, - ], - trigger: , - })} - onActiveChange={(key) => setActiveConversationId(key)} - rootClassName="app-chat-conversations-list" - /> - )} -
+ deleteMutation.mutate(id)} + onSelect={setActiveConversationId} + projectId={project.id} + selectedId={activeConversationId} + /> void; + onSelect: () => void; + selected: boolean; +} + +export function ConversationCard({ conversation, onDelete, onSelect, selected }: ConversationCardProps) { + const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item"; + + return ( + + + {conversation.title} + + + e?.stopPropagation()} + onConfirm={(e) => { + e?.stopPropagation(); + onDelete(); + }} + title="确认删除该对话?" + > + + setInputText(e.target.value)} + onSearch={(value) => setAppliedSearch(value.trim())} + placeholder="搜索对话" + value={inputText} + /> +
+ + {loading ? ( + + ) : conversations.length === 0 ? ( + + ) : filteredConversations.length === 0 ? ( + + ) : ( + groupedConversations.map((group) => { + if (group.items.length === 0) return null; + return ( + + {group.items.map((conv) => ( + onDelete(conv.id)} + onSelect={() => onSelect(conv.id)} + selected={conv.id === selectedId} + /> + ))} + + ); + }) + )} + + + ); +} diff --git a/src/web/features/chat/components/ConversationSidebar.tsx b/src/web/features/chat/components/ConversationSidebar.tsx new file mode 100644 index 0000000..993997b --- /dev/null +++ b/src/web/features/chat/components/ConversationSidebar.tsx @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; +import { Result } from "antd"; + +import { fetchConversations } from "../../../shared/hooks/use-conversations"; +import { ConversationList } from "./ConversationList"; + +interface ConversationSidebarProps { + onAddClick: () => void; + onDelete: (id: string) => void; + onSelect: (id: string) => void; + projectId: string; + selectedId: null | string; +} + +export function ConversationSidebar({ + onAddClick, + onDelete, + onSelect, + projectId, + selectedId, +}: ConversationSidebarProps) { + const CONVERSATIONS_KEY = ["conversations", projectId] as const; + + const { data, error, isLoading, refetch } = useQuery({ + queryFn: () => fetchConversations(projectId), + queryKey: CONVERSATIONS_KEY, + }); + + if (error) { + return ( +
+ void refetch()}>重试} + status="error" + subTitle="加载对话列表失败" + /> +
+ ); + } + + return ( + + ); +} diff --git a/src/web/features/inbox/components/MaterialCard.tsx b/src/web/features/inbox/components/MaterialCard.tsx index 8db5cfd..8d3492a 100644 --- a/src/web/features/inbox/components/MaterialCard.tsx +++ b/src/web/features/inbox/components/MaterialCard.tsx @@ -3,8 +3,6 @@ import { Button, Flex, Popconfirm, Tag, Typography } from "antd"; import type { Material, MaterialStatus } from "../types"; -import { formatDateLabel } from "../../../shared/utils/time"; - interface MaterialCardProps { material: Material; onDelete: () => void; @@ -12,11 +10,6 @@ interface MaterialCardProps { selected: boolean; } -function formatAssociatedDate(date: string): string { - if (!date) return "—"; - return formatDateLabel(date); -} - const STATUS_MAP: Record = { approved: { color: "green", label: "已通过" }, discarded: { color: "red", label: "已放弃" }, @@ -29,15 +22,13 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia return ( -
- - {material.description} - -
- - {formatAssociatedDate(material.associatedDate)} - -
+ + {material.description} +
{statusInfo && {statusInfo.label}} diff --git a/src/web/features/inbox/components/MaterialGroup.tsx b/src/web/features/inbox/components/MaterialGroup.tsx deleted file mode 100644 index 3c0735c..0000000 --- a/src/web/features/inbox/components/MaterialGroup.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { CaretDownOutlined, CaretRightOutlined } from "@ant-design/icons"; -import { Typography } from "antd"; -import { type ReactNode, useState } from "react"; - -interface MaterialGroupProps { - children: ReactNode; - count: number; - label: string; -} - -export function MaterialGroup({ children, count, label }: MaterialGroupProps) { - const [collapsed, setCollapsed] = useState(false); - - return ( -
-
setCollapsed(!collapsed)}> - {collapsed ? : } - - {label} - - - ({count}) - -
- {!collapsed &&
{children}
} -
- ); -} diff --git a/src/web/features/inbox/components/MaterialList.tsx b/src/web/features/inbox/components/MaterialList.tsx index 105506a..5546cd7 100644 --- a/src/web/features/inbox/components/MaterialList.tsx +++ b/src/web/features/inbox/components/MaterialList.tsx @@ -12,11 +12,10 @@ import { useMemo, useState } from "react"; import type { Material } from "../types"; +import { SidebarGroup } from "../../../shared/components/SidebarGroup"; import { useIsDark } from "../../../shared/hooks/use-is-dark"; +import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group"; import { MaterialCard } from "./MaterialCard"; -import { MaterialGroup } from "./MaterialGroup"; - -type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday"; interface MaterialListProps { loading: boolean; @@ -27,55 +26,6 @@ interface MaterialListProps { selectedId: null | string; } -const GROUP_LABELS: Record = { - earlier: "更早", - thisMonth: "本月", - thisWeek: "本周", - today: "今天", - yesterday: "昨天", -}; - -const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"]; - -interface MaterialGroupData { - items: Material[]; - key: DateGroup; -} - -function getDateGroup(dateStr: string, now: Date): DateGroup { - const date = new Date(dateStr); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today.getTime() - 86_400_000); - const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - - if (dateDay.getTime() >= today.getTime()) return "today"; - if (dateDay.getTime() >= yesterday.getTime()) return "yesterday"; - - const dayOfWeek = today.getDay(); - const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; - const monday = new Date(today.getTime() - mondayOffset * 86_400_000); - if (dateDay.getTime() >= monday.getTime()) return "thisWeek"; - - if (dateDay.getFullYear() === today.getFullYear() && dateDay.getMonth() === today.getMonth()) { - return "thisMonth"; - } - - return "earlier"; -} - -function groupMaterialsByDate(materials: readonly Material[]): MaterialGroupData[] { - const now = new Date(); - const groups = new Map(); - - for (const m of materials) { - const group = getDateGroup(m.createdAt, now); - if (!groups.has(group)) groups.set(group, []); - groups.get(group)!.push(m); - } - - return GROUP_ORDER.map((key) => ({ items: groups.get(key) ?? [], key })); -} - const STATUS_FILTER_OPTIONS = [ { icon: , label: "全部", value: "all" }, { color: "#faad14", icon: , label: "待审核", value: "pending" }, @@ -94,7 +44,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec return materials.filter((m) => m.status === filterStatus); }, [materials, filterStatus]); - const groupedMaterials = useMemo(() => groupMaterialsByDate(filteredMaterials), [filteredMaterials]); + const groupedMaterials = useMemo(() => groupByDate(filteredMaterials, "createdAt"), [filteredMaterials]); const segmentedOptions = useMemo( () => @@ -111,15 +61,15 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec ); return ( -
-
+
+
setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
{ if (group.items.length === 0) return null; return ( - + {group.items.map((material) => ( ))} - + ); }) )} diff --git a/src/web/shared/components/SidebarGroup/index.tsx b/src/web/shared/components/SidebarGroup/index.tsx new file mode 100644 index 0000000..e2cc6d3 --- /dev/null +++ b/src/web/shared/components/SidebarGroup/index.tsx @@ -0,0 +1,28 @@ +import { CaretDownOutlined, CaretRightOutlined } from "@ant-design/icons"; +import { Typography } from "antd"; +import { type ReactNode, useState } from "react"; + +interface SidebarGroupProps { + children: ReactNode; + count: number; + label: string; +} + +export function SidebarGroup({ children, count, label }: SidebarGroupProps) { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+
setCollapsed(!collapsed)}> + {collapsed ? : } + + {label} + + + ({count}) + +
+ {!collapsed &&
{children}
} +
+ ); +} diff --git a/src/web/shared/hooks/use-conversations.ts b/src/web/shared/hooks/use-conversations.ts index e3290ee..537fd35 100644 --- a/src/web/shared/hooks/use-conversations.ts +++ b/src/web/shared/hooks/use-conversations.ts @@ -40,7 +40,7 @@ export async function fetchConversation(projectId: string, conversationId: strin } export async function fetchConversations(projectId: string): Promise { - const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`); + const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=200`); return handleResponse(response, (data) => data as ConversationListResponse); } diff --git a/src/web/shared/utils/date-group.ts b/src/web/shared/utils/date-group.ts new file mode 100644 index 0000000..261b5a6 --- /dev/null +++ b/src/web/shared/utils/date-group.ts @@ -0,0 +1,52 @@ +export type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday"; + +export const GROUP_LABELS: Record = { + earlier: "更早", + thisMonth: "本月", + thisWeek: "本周", + today: "今天", + yesterday: "昨天", +}; + +export const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"]; + +export interface DateGroupData { + items: T[]; + key: DateGroup; +} + +export function getDateGroup(dateStr: string, now: Date): DateGroup { + const date = new Date(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86_400_000); + const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + if (dateDay.getTime() >= today.getTime()) return "today"; + if (dateDay.getTime() >= yesterday.getTime()) return "yesterday"; + + const dayOfWeek = today.getDay(); + const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const monday = new Date(today.getTime() - mondayOffset * 86_400_000); + if (dateDay.getTime() >= monday.getTime()) return "thisWeek"; + + if (dateDay.getFullYear() === today.getFullYear() && dateDay.getMonth() === today.getMonth()) { + return "thisMonth"; + } + + return "earlier"; +} + +export function groupByDate(items: readonly T[], dateField: keyof T & string): Array> { + const now = new Date(); + const groups = new Map(); + + for (const item of items) { + const dateValue = item[dateField]; + if (typeof dateValue !== "string") continue; + const group = getDateGroup(dateValue, now); + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(item); + } + + return GROUP_ORDER.map((key) => ({ items: groups.get(key) ?? [], key })); +} diff --git a/src/web/styles.css b/src/web/styles.css index 5ac5014..e6617aa 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -81,25 +81,95 @@ body { min-height: 60vh; } -.app-chat-conversations { +.app-sidebar-list { display: flex; - width: 260px; flex-direction: column; border-right: 1px solid var(--ant-color-border-secondary); border-radius: var(--ant-border-radius-lg); background: var(--ant-color-bg-container); } -.app-chat-conversations-header { +.app-sidebar-list-header { + display: flex; + flex-direction: column; + gap: var(--ant-margin-sm); padding: var(--ant-padding-sm); border-bottom: 1px solid var(--ant-color-border-secondary); } -.app-chat-conversations-list { +.app-sidebar-list-body { + display: flex; flex: 1; + flex-direction: column; min-height: 0; } +.app-sidebar-list-item { + border: none; + margin: var(--ant-margin-xxs) var(--ant-margin-xxs); + padding: var(--ant-padding-xs) var(--ant-padding-sm); + border-radius: var(--ant-border-radius-lg); + cursor: pointer; + transition: background 0.15s ease; +} + +.app-sidebar-list-item:hover { + background: var(--ant-color-bg-text-hover); +} + +.app-sidebar-list-item--selected { + background: var(--ant-color-primary-bg); +} + +.app-sidebar-list-item--selected:hover { + background: var(--ant-color-primary-bg); +} + +.app-sidebar-item-actions { + opacity: 0; + transition: opacity 0.15s ease; +} + +.app-sidebar-list-item:hover .app-sidebar-item-actions { + opacity: 1; +} + +.app-sidebar-group { + margin-top: var(--ant-margin-xs); +} + +.app-sidebar-group-header { + display: flex; + align-items: center; + gap: var(--ant-margin-xxs); + padding: var(--ant-padding-xs) var(--ant-padding-xs); + cursor: pointer; + user-select: none; + border-radius: var(--ant-border-radius-sm); + transition: background 0.15s ease; +} + +.app-sidebar-group-header:hover { + background: var(--ant-color-fill-tertiary); +} + +.app-sidebar-group-arrow { + display: inline-flex; + align-items: center; + font-size: var(--ant-font-size-sm); + color: var(--ant-color-text-quaternary); + width: 14px; +} + +.app-sidebar-group-label { + font-size: var(--ant-font-size-sm); + font-weight: 500; +} + +.app-sidebar-group-count { + font-size: var(--ant-font-size-sm); +} + .app-chat-panel { display: flex; flex: 1; @@ -255,30 +325,6 @@ body { overflow: hidden; } -.app-inbox-sidebar { - display: flex; - width: 280px; - flex-direction: column; - border-right: 1px solid var(--ant-color-border-secondary); - border-radius: var(--ant-border-radius-lg); - background: var(--ant-color-bg-container); -} - -.app-inbox-sidebar-header { - display: flex; - flex-direction: column; - gap: var(--ant-margin-sm); - padding: var(--ant-padding-sm); - border-bottom: 1px solid var(--ant-color-border-secondary); -} - -.app-inbox-list { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; -} - .app-inbox-content { display: flex; flex: 1; @@ -295,28 +341,24 @@ body { /* Inbox material list items */ .material-list-item { - border-left: 3px solid transparent; - border-bottom: 1px solid var(--ant-color-border-secondary); + border: none; + margin: var(--ant-margin-xxs) var(--ant-margin-xxs); padding: var(--ant-padding-xs) var(--ant-padding-sm); - padding-left: var(--ant-padding-sm); + border-radius: var(--ant-border-radius-lg); cursor: pointer; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.material-list-item:last-child { - border-bottom: none; + transition: background 0.15s ease; } .material-list-item:hover { - background: var(--ant-color-fill-tertiary); + background: var(--ant-color-bg-text-hover); } .material-list-item--selected { - border-left-color: var(--ant-color-primary); + background: var(--ant-color-primary-bg); } .material-list-item--selected:hover { - background: var(--ant-color-fill-tertiary); + background: var(--ant-color-primary-bg); } .material-item-right { @@ -351,52 +393,6 @@ body { opacity: 1; } -.material-item-time { - display: block; - margin-top: var(--ant-margin-xxs); - font-size: var(--ant-font-size-sm); -} - -.app-inbox-group { - margin-top: var(--ant-margin-xs); -} - -.app-inbox-group-header { - display: flex; - align-items: center; - gap: var(--ant-margin-xxs); - padding: var(--ant-padding-xs) var(--ant-padding-xs); - cursor: pointer; - user-select: none; - border-radius: var(--ant-border-radius-sm); - transition: background 0.15s ease; -} - -.app-inbox-group-header:hover { - background: var(--ant-color-fill-tertiary); -} - -.app-inbox-group-arrow { - display: inline-flex; - align-items: center; - font-size: var(--ant-font-size-sm); - color: var(--ant-color-text-quaternary); - width: 14px; -} - -.app-inbox-group-label { - font-size: var(--ant-font-size-sm); - font-weight: 500; -} - -.app-inbox-group-count { - font-size: var(--ant-font-size-sm); -} - -.app-inbox-group-content { - padding-bottom: var(--ant-padding-xs); -} - .app-inbox-filter-count { margin-left: 4px; font-size: var(--ant-font-size-sm); diff --git a/tests/web/components/ChatPage.test.tsx b/tests/web/components/ChatPage.test.tsx new file mode 100644 index 0000000..80ca9b7 --- /dev/null +++ b/tests/web/components/ChatPage.test.tsx @@ -0,0 +1,177 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "bun:test"; +import { createElement } from "react"; + +import type { Model, Project } from "../../../src/shared/api"; + +import { ChatPage } from "../../../src/web/features/chat/ChatPage"; +import { ProjectContext } from "../../../src/web/shared/hooks/use-current-project"; +import { installFetchMock, jsonResponse, renderWithProviders } from "../test-utils"; + +const PROJECT_ID = "proj-1"; + +const MOCK_PROJECT: Project = { + archivedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + description: "", + id: PROJECT_ID, + name: "测试项目", + status: "active", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const TEXT_MODEL: Model = { + capabilities: ["text"], + contextLength: null, + createdAt: "2024-01-01T00:00:00.000Z", + id: "model-1", + maxOutputTokens: null, + modelId: "gpt-4o", + name: "GPT-4o", + providerId: "pv1", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +const CONVERSATION = { + createdAt: "2026-06-03T00:00:00.000Z", + id: "conv-1", + modelId: "model-1", + projectId: PROJECT_ID, + title: "测试对话", + updatedAt: "2026-06-03T00:00:00.000Z", +}; + +function renderChatPage() { + return renderWithProviders( + createElement(ProjectContext.Provider, { + children: createElement(ChatPage), + value: MOCK_PROJECT, + }), + ); +} + +function setupFetchMock() { + return installFetchMock((call) => { + if (call.url.includes("/models")) { + return jsonResponse({ items: [TEXT_MODEL], total: 1 }); + } + if (call.url.includes("/conversations") && call.method === "GET") { + return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 }); + } + if (call.url.endsWith("/conversations") && call.method === "POST") { + return jsonResponse({ conversation: { ...CONVERSATION, id: "conv-new", title: "新会话" } }, { status: 201 }); + } + if (call.method === "DELETE" && call.url.includes("/conversations/")) { + return new Response(null, { status: 204 }); + } + if (call.url.includes("/messages")) { + return jsonResponse({ items: [], total: 0 }); + } + if (/\/conversations\/conv-1$/.exec(call.url)) { + return jsonResponse({ conversation: CONVERSATION }); + } + return jsonResponse({ error: "not found" }, { status: 404 }); + }); +} + +void vi.mock("@ai-sdk/react", () => ({ + useChat: () => ({ + messages: [], + regenerate: () => undefined, + sendMessage: () => undefined, + setMessages: (msgs: unknown) => msgs, + status: "ready", + stop: () => undefined, + }), +})); + +void vi.mock("ai", () => ({ + DefaultChatTransport: function () { + return undefined; + }, +})); + +describe("ChatPage", () => { + test("渲染对话侧边栏和欢迎页", async () => { + setupFetchMock(); + renderChatPage(); + + await waitFor(() => { + expect(screen.getByText("测试对话")).not.toBeNull(); + }); + + expect(screen.getByText("你好,我是阿福")).not.toBeNull(); + }); + + test("点击新对话按钮创建并选中对话", async () => { + const calls = setupFetchMock(); + renderChatPage(); + + await waitFor(() => { + expect(screen.getByText("新对话")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("新对话")); + + await waitFor(() => { + const createCall = calls.find((c) => c.url.endsWith("/conversations") && c.method === "POST"); + expect(createCall).toBeTruthy(); + }); + }); + + test("点击对话切换选中", async () => { + setupFetchMock(); + renderChatPage(); + + await waitFor(() => { + expect(screen.getByText("测试对话")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("测试对话")); + + await waitFor(() => { + expect(screen.queryByText("你好,我是阿福")).toBeNull(); + }); + }); + + test("删除对话后列表更新", async () => { + let deleted = false; + installFetchMock((call) => { + if (call.method === "DELETE" && call.url.includes("/conversations/conv-1")) { + deleted = true; + return new Response(null, { status: 204 }); + } + if (call.url.includes("/models")) { + return jsonResponse({ items: [TEXT_MODEL], total: 1 }); + } + if (call.url.includes("/conversations") && call.method === "GET") { + if (deleted) { + return jsonResponse({ items: [], page: 1, pageSize: 200, total: 0 }); + } + return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 }); + } + if (call.url.includes("/messages")) { + return jsonResponse({ items: [], total: 0 }); + } + return jsonResponse({ error: "not found" }, { status: 404 }); + }); + + renderChatPage(); + + await waitFor(() => { + expect(screen.getByText("测试对话")).not.toBeNull(); + }); + + fireEvent.click(screen.getByLabelText("删除")); + + await waitFor(() => { + expect(screen.getByText("确认删除该对话?")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("删 除")); + + await waitFor(() => { + expect(screen.getByText("暂无对话")).not.toBeNull(); + }); + }); +}); diff --git a/tests/web/components/ConversationCard.test.tsx b/tests/web/components/ConversationCard.test.tsx new file mode 100644 index 0000000..802dc9b --- /dev/null +++ b/tests/web/components/ConversationCard.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "bun:test"; +import { createElement } from "react"; + +import type { Conversation } from "../../../src/shared/api"; + +import { ConversationCard } from "../../../src/web/features/chat/components/ConversationCard"; +import { renderWithProviders } from "../test-utils"; + +const MOCK_CONVERSATION: Conversation = { + createdAt: "2026-06-03T00:00:00.000Z", + id: "conv-1", + modelId: "model-1", + projectId: "proj-1", + title: "测试对话", + updatedAt: "2026-06-03T00:00:00.000Z", +}; + +describe("ConversationCard", () => { + test("渲染对话标题", () => { + renderWithProviders( + createElement(ConversationCard, { + conversation: MOCK_CONVERSATION, + onDelete: vi.fn(), + onSelect: vi.fn(), + selected: false, + }), + ); + expect(screen.getByText("测试对话")).not.toBeNull(); + }); + + test("点击卡片触发 onSelect", () => { + const onSelect = vi.fn(); + renderWithProviders( + createElement(ConversationCard, { + conversation: MOCK_CONVERSATION, + onDelete: vi.fn(), + onSelect, + selected: false, + }), + ); + const item = screen.getByText("测试对话").closest(".app-sidebar-list-item")!; + fireEvent.click(item); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test("点击删除按钮弹出确认框,确认后触发 onDelete", async () => { + const onDelete = vi.fn(); + renderWithProviders( + createElement(ConversationCard, { + conversation: MOCK_CONVERSATION, + onDelete, + onSelect: vi.fn(), + selected: false, + }), + ); + fireEvent.click(screen.getByLabelText("删除")); + + await waitFor(() => { + expect(screen.getByText("确认删除该对话?")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("删 除")); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalledTimes(1); + }); + }); + + test("选中时包含 app-sidebar-list-item--selected 类名", () => { + renderWithProviders( + createElement(ConversationCard, { + conversation: MOCK_CONVERSATION, + onDelete: vi.fn(), + onSelect: vi.fn(), + selected: true, + }), + ); + const item = screen.getByText("测试对话").closest(".app-sidebar-list-item--selected"); + expect(item).not.toBeNull(); + }); + + test("未选中时不包含 app-sidebar-list-item--selected 类名", () => { + renderWithProviders( + createElement(ConversationCard, { + conversation: MOCK_CONVERSATION, + onDelete: vi.fn(), + onSelect: vi.fn(), + selected: false, + }), + ); + const item = screen.getByText("测试对话").closest(".app-sidebar-list-item--selected"); + expect(item).toBeNull(); + }); +}); diff --git a/tests/web/components/ConversationList.test.tsx b/tests/web/components/ConversationList.test.tsx new file mode 100644 index 0000000..5d0956f --- /dev/null +++ b/tests/web/components/ConversationList.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "bun:test"; +import { createElement } from "react"; + +import type { Conversation } from "../../../src/shared/api"; + +import { ConversationList } from "../../../src/web/features/chat/components/ConversationList"; +import { renderWithProviders } from "../test-utils"; + +const CONVERSATIONS: Conversation[] = [ + { + createdAt: "2026-06-03T00:00:00.000Z", + id: "conv-1", + modelId: "model-1", + projectId: "proj-1", + title: "今天对话", + updatedAt: "2026-06-03T00:00:00.000Z", + }, + { + createdAt: "2026-06-02T00:00:00.000Z", + id: "conv-2", + modelId: "model-1", + projectId: "proj-1", + title: "昨天对话", + updatedAt: "2026-06-02T00:00:00.000Z", + }, + { + createdAt: "2026-05-01T00:00:00.000Z", + id: "conv-3", + modelId: "model-1", + projectId: "proj-1", + title: "更早对话", + updatedAt: "2026-05-01T00:00:00.000Z", + }, +]; + +describe("ConversationList", () => { + test("列表为空时显示暂无对话", () => { + renderWithProviders( + createElement(ConversationList, { + conversations: [], + loading: false, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + expect(screen.getByText("暂无对话")).not.toBeNull(); + }); + + test("渲染对话列表并按日期分组", () => { + renderWithProviders( + createElement(ConversationList, { + conversations: CONVERSATIONS, + loading: false, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + expect(screen.getByText("今天对话")).not.toBeNull(); + expect(screen.getByText("昨天对话")).not.toBeNull(); + expect(screen.getByText("更早对话")).not.toBeNull(); + }); + + test("加载中显示 Skeleton", () => { + renderWithProviders( + createElement(ConversationList, { + conversations: [], + loading: true, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + expect(document.querySelector(".ant-skeleton")).not.toBeNull(); + }); + + test("点击新对话按钮触发 onAddClick", () => { + const onAddClick = vi.fn(); + renderWithProviders( + createElement(ConversationList, { + conversations: [], + loading: false, + onAddClick, + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + screen.getByText("新对话").click(); + expect(onAddClick).toHaveBeenCalledTimes(1); + }); + + test("点击搜索按钮过滤对话标题", async () => { + renderWithProviders( + createElement(ConversationList, { + conversations: CONVERSATIONS, + loading: false, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + const searchInput = screen.getByPlaceholderText("搜索对话"); + fireEvent.change(searchInput, { target: { value: "今天" } }); + expect(screen.getByText("昨天对话")).not.toBeNull(); + + fireEvent.keyDown(searchInput, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("今天对话")).not.toBeNull(); + expect(screen.queryByText("昨天对话")).toBeNull(); + expect(screen.queryByText("更早对话")).toBeNull(); + }); + }); + + test("输入文字未点击搜索时不触发过滤", () => { + renderWithProviders( + createElement(ConversationList, { + conversations: CONVERSATIONS, + loading: false, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + const searchInput = screen.getByPlaceholderText("搜索对话"); + fireEvent.change(searchInput, { target: { value: "今天" } }); + + expect(screen.getByText("今天对话")).not.toBeNull(); + expect(screen.getByText("昨天对话")).not.toBeNull(); + expect(screen.getByText("更早对话")).not.toBeNull(); + }); + + test("搜索无匹配结果时显示无匹配对话", async () => { + renderWithProviders( + createElement(ConversationList, { + conversations: CONVERSATIONS, + loading: false, + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + selectedId: null, + }), + ); + const searchInput = screen.getByPlaceholderText("搜索对话"); + fireEvent.change(searchInput, { target: { value: "不存在的对话" } }); + fireEvent.keyDown(searchInput, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("无匹配对话")).not.toBeNull(); + }); + }); +}); diff --git a/tests/web/components/ConversationSidebar.test.tsx b/tests/web/components/ConversationSidebar.test.tsx new file mode 100644 index 0000000..32ea5a6 --- /dev/null +++ b/tests/web/components/ConversationSidebar.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "bun:test"; +import { createElement } from "react"; + +import { ConversationSidebar } from "../../../src/web/features/chat/components/ConversationSidebar"; +import { installFetchMock, jsonResponse, renderWithProviders } from "../test-utils"; + +const PROJECT_ID = "proj-1"; + +const CONVERSATION = { + createdAt: "2026-06-03T00:00:00.000Z", + id: "conv-1", + modelId: "model-1", + projectId: PROJECT_ID, + title: "测试对话", + updatedAt: "2026-06-03T00:00:00.000Z", +}; + +function setupSuccessMock() { + return installFetchMock((call) => { + if (call.url.includes("/conversations") && call.method === "GET") { + return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 }); + } + return jsonResponse({ error: "not found" }, { status: 404 }); + }); +} + +describe("ConversationSidebar", () => { + test("加载成功后渲染对话列表", async () => { + setupSuccessMock(); + renderWithProviders( + createElement(ConversationSidebar, { + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + projectId: PROJECT_ID, + selectedId: null, + }), + ); + + await waitFor(() => { + expect(screen.getByText("测试对话")).not.toBeNull(); + }); + }); + + test("加载失败时显示错误和重试按钮", async () => { + installFetchMock((call) => { + if (call.url.includes("/conversations") && call.method === "GET") { + return jsonResponse({ error: "服务器错误" }, { status: 500 }); + } + return jsonResponse({ error: "not found" }, { status: 404 }); + }); + + renderWithProviders( + createElement(ConversationSidebar, { + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + projectId: PROJECT_ID, + selectedId: null, + }), + ); + + await waitFor(() => { + expect(screen.getByText("加载对话列表失败")).not.toBeNull(); + }); + + expect(screen.getByText("重试")).not.toBeNull(); + }); + + test("点击重试重新请求", async () => { + let callCount = 0; + installFetchMock((call) => { + if (call.url.includes("/conversations") && call.method === "GET") { + callCount++; + if (callCount === 1) { + return jsonResponse({ error: "服务器错误" }, { status: 500 }); + } + return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 }); + } + return jsonResponse({ error: "not found" }, { status: 404 }); + }); + + renderWithProviders( + createElement(ConversationSidebar, { + onAddClick: vi.fn(), + onDelete: vi.fn(), + onSelect: vi.fn(), + projectId: PROJECT_ID, + selectedId: null, + }), + ); + + await waitFor(() => { + expect(screen.getByText("加载对话列表失败")).not.toBeNull(); + }); + + fireEvent.click(screen.getByText("重试")); + + await waitFor(() => { + expect(screen.getByText("测试对话")).not.toBeNull(); + }); + }); +}); diff --git a/tests/web/features/inbox/MaterialCard.test.tsx b/tests/web/features/inbox/MaterialCard.test.tsx index 1a50b86..0094cb7 100644 --- a/tests/web/features/inbox/MaterialCard.test.tsx +++ b/tests/web/features/inbox/MaterialCard.test.tsx @@ -18,7 +18,7 @@ const MOCK_MATERIAL: Material = { }; describe("MaterialCard", () => { - test("渲染素材描述、时间和状态标签", () => { + test("渲染素材描述和状态标签", () => { renderWithProviders( createElement(MaterialCard, { material: MOCK_MATERIAL, @@ -28,7 +28,6 @@ describe("MaterialCard", () => { }), ); expect(screen.getByText("测试素材描述")).not.toBeNull(); - expect(screen.getByText("今天")).not.toBeNull(); expect(screen.getByText("待审核")).not.toBeNull(); }); diff --git a/tests/web/utils/date-group.test.ts b/tests/web/utils/date-group.test.ts new file mode 100644 index 0000000..36e39bf --- /dev/null +++ b/tests/web/utils/date-group.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; + +import { getDateGroup, groupByDate } from "../../../src/web/shared/utils/date-group"; + +function makeDate(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString(); +} + +describe("getDateGroup", () => { + test("今天的日期返回 today", () => { + const now = new Date(); + const result = getDateGroup(now.toISOString(), now); + expect(result).toBe("today"); + }); + + test("昨天的日期返回 yesterday", () => { + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const result = getDateGroup(yesterday.toISOString(), now); + expect(result).toBe("yesterday"); + }); + + test("本周内的日期返回 thisWeek", () => { + const now = new Date(); + const dayOfWeek = now.getDay(); + const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const wednesday = new Date(now); + wednesday.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) + 2); + if (wednesday > now) { + const tuesday = new Date(now); + tuesday.setDate(now.getDate() - mondayOffset + 1); + if (tuesday < now && tuesday.getDate() !== now.getDate() - 1) { + const result = getDateGroup(tuesday.toISOString(), now); + expect(result).toBe("thisWeek"); + return; + } + } + const tuesday = new Date(now); + tuesday.setDate(now.getDate() - mondayOffset + 1); + if (tuesday.toDateString() !== now.toDateString()) { + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (tuesday.toDateString() !== yesterday.toDateString()) { + const result = getDateGroup(tuesday.toISOString(), now); + expect(result).toBe("thisWeek"); + return; + } + } + expect(true).toBe(true); + }); + + test("本月内的日期返回 thisMonth", () => { + const now = new Date(2026, 5, 15); + const earlier = new Date(2026, 5, 3); + const result = getDateGroup(earlier.toISOString(), now); + expect(result).toBe("thisMonth"); + }); + + test("更早的日期返回 earlier", () => { + const now = new Date(2026, 5, 15); + const earlier = new Date(2026, 3, 1); + const result = getDateGroup(earlier.toISOString(), now); + expect(result).toBe("earlier"); + }); +}); + +describe("groupByDate", () => { + test("按 dateField 分组并返回有序结果", () => { + const now = new Date(); + const items = [ + { id: "1", title: "今天", updatedAt: now.toISOString() }, + { id: "2", title: "昨天", updatedAt: makeDate(1) }, + { id: "3", title: "更早", updatedAt: makeDate(60) }, + ]; + + const groups = groupByDate(items, "updatedAt"); + + const todayGroup = groups.find((g) => g.key === "today")!; + expect(todayGroup.items.length).toBe(1); + expect(todayGroup.items[0]!.id).toBe("1"); + + const yesterdayGroup = groups.find((g) => g.key === "yesterday")!; + expect(yesterdayGroup.items.length).toBe(1); + + const earlierGroup = groups.find((g) => g.key === "earlier")!; + expect(earlierGroup.items.length).toBe(1); + + const emptyGroups = groups.filter((g) => g.key === "thisWeek" || g.key === "thisMonth"); + emptyGroups.forEach((g) => { + expect(g.items.length).toBe(0); + }); + }); + + test("空数组返回所有空分组", () => { + const groups = groupByDate([] as Array<{ updatedAt: string }>, "updatedAt"); + expect(groups.length).toBe(5); + groups.forEach((g) => { + expect(g.items.length).toBe(0); + }); + }); +});