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 (
-
-
- }
- onClick={() => {
- 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}`);
- });
- }}
- type="primary"
- >
- 新对话
-
-
- {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="确认删除该对话?"
+ >
+ }
+ onClick={(e) => e.stopPropagation()}
+ size="small"
+ type="text"
+ />
+
+
+
+ );
+}
diff --git a/src/web/features/chat/components/ConversationList.tsx b/src/web/features/chat/components/ConversationList.tsx
new file mode 100644
index 0000000..f2c34ff
--- /dev/null
+++ b/src/web/features/chat/components/ConversationList.tsx
@@ -0,0 +1,94 @@
+import { PlusOutlined } from "@ant-design/icons";
+import { Button, Empty, Input, Skeleton } from "antd";
+import "overlayscrollbars/styles/overlayscrollbars.css";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
+import { useMemo, useState } from "react";
+
+import type { Conversation } from "../../../../shared/api";
+
+import { SidebarGroup } from "../../../shared/components/SidebarGroup";
+import { useIsDark } from "../../../shared/hooks/use-is-dark";
+import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
+import { ConversationCard } from "./ConversationCard";
+
+interface ConversationListProps {
+ conversations: readonly Conversation[];
+ loading: boolean;
+ onAddClick: () => void;
+ onDelete: (id: string) => void;
+ onSelect: (id: string) => void;
+ selectedId: null | string;
+}
+
+export function ConversationList({
+ conversations,
+ loading,
+ onAddClick,
+ onDelete,
+ onSelect,
+ selectedId,
+}: ConversationListProps) {
+ const [inputText, setInputText] = useState("");
+ const [appliedSearch, setAppliedSearch] = useState("");
+ const isDark = useIsDark();
+
+ const filteredConversations = useMemo(() => {
+ if (!appliedSearch) return conversations;
+ const lower = appliedSearch.toLowerCase();
+ return conversations.filter((c) => c.title.toLowerCase().includes(lower));
+ }, [conversations, appliedSearch]);
+
+ const groupedConversations = useMemo(() => groupByDate(filteredConversations, "updatedAt"), [filteredConversations]);
+
+ return (
+
+
+ } onClick={onAddClick} type="primary">
+ 新对话
+
+ 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 (
-
-
+
+
} onClick={onAddClick} type="primary">
新增素材
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);
+ });
+ });
+});