feat: 用自定义侧边栏替换聊天室 Conversations 组件,提取公共 SidebarGroup 和 date-group
This commit is contained in:
@@ -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(四态)。支持编辑重发、重新生成、复制。
|
- **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)。
|
- **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) |
|
| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) |
|
||||||
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
|
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
|
||||||
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
|
| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
|
||||||
|
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
|
||||||
|
|
||||||
### 共享 Hooks
|
### 共享 Hooks
|
||||||
|
|
||||||
@@ -70,10 +71,11 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
|||||||
|
|
||||||
### 共享工具函数
|
### 共享工具函数
|
||||||
|
|
||||||
| 文件 | 导出 |
|
| 文件 | 导出 |
|
||||||
| --------------- | --------------------------------------------------------------------------------------------- |
|
| --------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
| `utils/api.ts` | `handleResponse(response, extract)`、`handleVoidResponse(response)` |
|
| `utils/api.ts` | `handleResponse(response, extract)`、`handleVoidResponse(response)` |
|
||||||
| `utils/time.ts` | `formatCountdown`、`formatDurationUnit`、`formatRelativeTime`、`isOlderThan`、`subtractHours` |
|
| `utils/time.ts` | `formatCountdown`、`formatDurationUnit`、`formatRelativeTime`、`isOlderThan`、`subtractHours` |
|
||||||
|
| `utils/date-group.ts` | `getDateGroup`、`groupByDate`、`GROUP_LABELS`、`GROUP_ORDER`、`DateGroup`、`DateGroupData` |
|
||||||
|
|
||||||
## 更新触发条件
|
## 更新触发条件
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { DeleteOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Conversations } from "@ant-design/x";
|
import { App } from "antd";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { App, Button, Spin } from "antd";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { Conversation } from "../../../shared/api";
|
import { createConversation, deleteConversation } from "../../shared/hooks/use-conversations";
|
||||||
|
|
||||||
import { createConversation, deleteConversation, fetchConversations } from "../../shared/hooks/use-conversations";
|
|
||||||
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
||||||
import { useModelList } from "../../shared/hooks/use-models";
|
import { useModelList } from "../../shared/hooks/use-models";
|
||||||
import { ChatPanel } from "./ChatPanel";
|
import { ChatPanel } from "./ChatPanel";
|
||||||
|
import { ConversationSidebar } from "./components/ConversationSidebar";
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const project = useCurrentProject();
|
const project = useCurrentProject();
|
||||||
@@ -19,11 +16,6 @@ export function ChatPage() {
|
|||||||
|
|
||||||
const CONVERSATIONS_KEY = ["conversations", project.id] as const;
|
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 { data: modelsData } = useModelList({ pageSize: 200 });
|
||||||
|
|
||||||
const textModels = useMemo(
|
const textModels = useMemo(
|
||||||
@@ -44,58 +36,26 @@ export function ChatPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const conversations = useMemo(
|
const handleAddConversation = () => {
|
||||||
() => (data?.items ?? []).map((c: Conversation) => ({ key: c.id, label: c.title })),
|
void createConversation(project.id, defaultModelId ?? undefined)
|
||||||
[data],
|
.then((conv) => {
|
||||||
);
|
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
|
||||||
|
setActiveConversationId(conv.id);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
void message.error(`创建会话失败:${err.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-page">
|
<div className="app-chat-page">
|
||||||
<div className="app-chat-conversations">
|
<ConversationSidebar
|
||||||
<div className="app-chat-conversations-header">
|
onAddClick={handleAddConversation}
|
||||||
<Button
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
block
|
onSelect={setActiveConversationId}
|
||||||
icon={<PlusOutlined />}
|
projectId={project.id}
|
||||||
onClick={() => {
|
selectedId={activeConversationId}
|
||||||
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"
|
|
||||||
>
|
|
||||||
新对话
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Spin />
|
|
||||||
) : (
|
|
||||||
<Conversations
|
|
||||||
activeKey={activeConversationId ?? ""}
|
|
||||||
items={conversations}
|
|
||||||
menu={(conv) => ({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
danger: true,
|
|
||||||
icon: <DeleteOutlined />,
|
|
||||||
key: "delete",
|
|
||||||
label: "删除",
|
|
||||||
onClick: () => {
|
|
||||||
deleteMutation.mutate(conv.key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
trigger: <MoreOutlined />,
|
|
||||||
})}
|
|
||||||
onActiveChange={(key) => setActiveConversationId(key)}
|
|
||||||
rootClassName="app-chat-conversations-list"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
conversationId={activeConversationId}
|
conversationId={activeConversationId}
|
||||||
defaultModelId={defaultModelId}
|
defaultModelId={defaultModelId}
|
||||||
|
|||||||
45
src/web/features/chat/components/ConversationCard.tsx
Normal file
45
src/web/features/chat/components/ConversationCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Flex, Popconfirm, Typography } from "antd";
|
||||||
|
|
||||||
|
import type { Conversation } from "../../../../shared/api";
|
||||||
|
|
||||||
|
interface ConversationCardProps {
|
||||||
|
conversation: Conversation;
|
||||||
|
onDelete: () => 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 (
|
||||||
|
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
||||||
|
<Typography.Text ellipsis style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{conversation.title}
|
||||||
|
</Typography.Text>
|
||||||
|
<span className="app-sidebar-item-actions">
|
||||||
|
<Popconfirm
|
||||||
|
description="删除后不可恢复"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
okText="删除"
|
||||||
|
onCancel={(e) => e?.stopPropagation()}
|
||||||
|
onConfirm={(e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
title="确认删除该对话?"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label="删除"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/web/features/chat/components/ConversationList.tsx
Normal file
94
src/web/features/chat/components/ConversationList.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="app-sidebar-list" style={{ width: 260 }}>
|
||||||
|
<div className="app-sidebar-list-header">
|
||||||
|
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
|
||||||
|
新对话
|
||||||
|
</Button>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
onSearch={(value) => setAppliedSearch(value.trim())}
|
||||||
|
placeholder="搜索对话"
|
||||||
|
value={inputText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="app-sidebar-list-body"
|
||||||
|
options={{
|
||||||
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
|
scrollbars: {
|
||||||
|
autoHide: "move",
|
||||||
|
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} title={false} />
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<Empty description="暂无对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : filteredConversations.length === 0 ? (
|
||||||
|
<Empty description="无匹配对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
groupedConversations.map((group) => {
|
||||||
|
if (group.items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
|
||||||
|
{group.items.map((conv) => (
|
||||||
|
<ConversationCard
|
||||||
|
conversation={conv}
|
||||||
|
key={conv.id}
|
||||||
|
onDelete={() => onDelete(conv.id)}
|
||||||
|
onSelect={() => onSelect(conv.id)}
|
||||||
|
selected={conv.id === selectedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/web/features/chat/components/ConversationSidebar.tsx
Normal file
51
src/web/features/chat/components/ConversationSidebar.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="app-sidebar-list" style={{ width: 260 }}>
|
||||||
|
<Result
|
||||||
|
extra={<button onClick={() => void refetch()}>重试</button>}
|
||||||
|
status="error"
|
||||||
|
subTitle="加载对话列表失败"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConversationList
|
||||||
|
conversations={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
onAddClick={onAddClick}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSelect={onSelect}
|
||||||
|
selectedId={selectedId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ import { Button, Flex, Popconfirm, Tag, Typography } from "antd";
|
|||||||
|
|
||||||
import type { Material, MaterialStatus } from "../types";
|
import type { Material, MaterialStatus } from "../types";
|
||||||
|
|
||||||
import { formatDateLabel } from "../../../shared/utils/time";
|
|
||||||
|
|
||||||
interface MaterialCardProps {
|
interface MaterialCardProps {
|
||||||
material: Material;
|
material: Material;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -12,11 +10,6 @@ interface MaterialCardProps {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAssociatedDate(date: string): string {
|
|
||||||
if (!date) return "—";
|
|
||||||
return formatDateLabel(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
||||||
approved: { color: "green", label: "已通过" },
|
approved: { color: "green", label: "已通过" },
|
||||||
discarded: { color: "red", label: "已放弃" },
|
discarded: { color: "red", label: "已放弃" },
|
||||||
@@ -29,15 +22,13 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<Typography.Paragraph
|
||||||
<Typography.Text ellipsis strong={selected}>
|
className="material-item-desc"
|
||||||
{material.description}
|
ellipsis={{ rows: 2 }}
|
||||||
</Typography.Text>
|
style={{ flex: 1, margin: 0, minWidth: 0 }}
|
||||||
<br />
|
>
|
||||||
<Typography.Text className="material-item-time" type="secondary">
|
{material.description}
|
||||||
{formatAssociatedDate(material.associatedDate)}
|
</Typography.Paragraph>
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div className="material-item-right">
|
<div className="material-item-right">
|
||||||
<span className="material-item-tag">
|
<span className="material-item-tag">
|
||||||
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
|
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="app-inbox-group">
|
|
||||||
<div className="app-inbox-group-header" onClick={() => setCollapsed(!collapsed)}>
|
|
||||||
<span className="app-inbox-group-arrow">{collapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}</span>
|
|
||||||
<Typography.Text className="app-inbox-group-label" type="secondary">
|
|
||||||
{label}
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text className="app-inbox-group-count" type="secondary">
|
|
||||||
({count})
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
{!collapsed && <div className="app-inbox-group-content">{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,11 +12,10 @@ import { useMemo, useState } from "react";
|
|||||||
|
|
||||||
import type { Material } from "../types";
|
import type { Material } from "../types";
|
||||||
|
|
||||||
|
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
|
||||||
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
||||||
|
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
|
||||||
import { MaterialCard } from "./MaterialCard";
|
import { MaterialCard } from "./MaterialCard";
|
||||||
import { MaterialGroup } from "./MaterialGroup";
|
|
||||||
|
|
||||||
type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
|
|
||||||
|
|
||||||
interface MaterialListProps {
|
interface MaterialListProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -27,55 +26,6 @@ interface MaterialListProps {
|
|||||||
selectedId: null | string;
|
selectedId: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_LABELS: Record<DateGroup, string> = {
|
|
||||||
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<DateGroup, Material[]>();
|
|
||||||
|
|
||||||
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 = [
|
const STATUS_FILTER_OPTIONS = [
|
||||||
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
||||||
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
|
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
|
||||||
@@ -94,7 +44,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
|||||||
return materials.filter((m) => m.status === filterStatus);
|
return materials.filter((m) => m.status === filterStatus);
|
||||||
}, [materials, filterStatus]);
|
}, [materials, filterStatus]);
|
||||||
|
|
||||||
const groupedMaterials = useMemo(() => groupMaterialsByDate(filteredMaterials), [filteredMaterials]);
|
const groupedMaterials = useMemo(() => groupByDate(filteredMaterials, "createdAt"), [filteredMaterials]);
|
||||||
|
|
||||||
const segmentedOptions = useMemo(
|
const segmentedOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -111,15 +61,15 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-sidebar">
|
<div className="app-sidebar-list" style={{ width: 260 }}>
|
||||||
<div className="app-inbox-sidebar-header">
|
<div className="app-sidebar-list-header">
|
||||||
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
|
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
|
||||||
新增素材
|
新增素材
|
||||||
</Button>
|
</Button>
|
||||||
<Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
|
<Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
|
||||||
</div>
|
</div>
|
||||||
<OverlayScrollbarsComponent
|
<OverlayScrollbarsComponent
|
||||||
className="app-inbox-list"
|
className="app-sidebar-list-body"
|
||||||
options={{
|
options={{
|
||||||
overflow: { x: "hidden", y: "scroll" },
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
scrollbars: {
|
scrollbars: {
|
||||||
@@ -138,7 +88,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
|||||||
groupedMaterials.map((group) => {
|
groupedMaterials.map((group) => {
|
||||||
if (group.items.length === 0) return null;
|
if (group.items.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<MaterialGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
|
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
|
||||||
{group.items.map((material) => (
|
{group.items.map((material) => (
|
||||||
<MaterialCard
|
<MaterialCard
|
||||||
key={material.id}
|
key={material.id}
|
||||||
@@ -148,7 +98,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
|||||||
selected={material.id === selectedId}
|
selected={material.id === selectedId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</MaterialGroup>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
28
src/web/shared/components/SidebarGroup/index.tsx
Normal file
28
src/web/shared/components/SidebarGroup/index.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="app-sidebar-group">
|
||||||
|
<div className="app-sidebar-group-header" onClick={() => setCollapsed(!collapsed)}>
|
||||||
|
<span className="app-sidebar-group-arrow">{collapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}</span>
|
||||||
|
<Typography.Text className="app-sidebar-group-label" type="secondary">
|
||||||
|
{label}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text className="app-sidebar-group-count" type="secondary">
|
||||||
|
({count})
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
{!collapsed && <div className="app-sidebar-group-content">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export async function fetchConversation(projectId: string, conversationId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
|
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
|
||||||
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);
|
return handleResponse(response, (data) => data as ConversationListResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
src/web/shared/utils/date-group.ts
Normal file
52
src/web/shared/utils/date-group.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
|
||||||
|
|
||||||
|
export const GROUP_LABELS: Record<DateGroup, string> = {
|
||||||
|
earlier: "更早",
|
||||||
|
thisMonth: "本月",
|
||||||
|
thisWeek: "本周",
|
||||||
|
today: "今天",
|
||||||
|
yesterday: "昨天",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"];
|
||||||
|
|
||||||
|
export interface DateGroupData<T> {
|
||||||
|
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<T>(items: readonly T[], dateField: keyof T & string): Array<DateGroupData<T>> {
|
||||||
|
const now = new Date();
|
||||||
|
const groups = new Map<DateGroup, T[]>();
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -81,25 +81,95 @@ body {
|
|||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-conversations {
|
.app-sidebar-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 260px;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--ant-color-border-secondary);
|
border-right: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: var(--ant-border-radius-lg);
|
border-radius: var(--ant-border-radius-lg);
|
||||||
background: var(--ant-color-bg-container);
|
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);
|
padding: var(--ant-padding-sm);
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chat-conversations-list {
|
.app-sidebar-list-body {
|
||||||
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 0;
|
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 {
|
.app-chat-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -255,30 +325,6 @@ body {
|
|||||||
overflow: hidden;
|
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 {
|
.app-inbox-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -295,28 +341,24 @@ body {
|
|||||||
|
|
||||||
/* Inbox material list items */
|
/* Inbox material list items */
|
||||||
.material-list-item {
|
.material-list-item {
|
||||||
border-left: 3px solid transparent;
|
border: none;
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
margin: var(--ant-margin-xxs) var(--ant-margin-xxs);
|
||||||
padding: var(--ant-padding-xs) var(--ant-padding-sm);
|
padding: var(--ant-padding-xs) var(--ant-padding-sm);
|
||||||
padding-left: var(--ant-padding-sm);
|
border-radius: var(--ant-border-radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s ease, background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
|
||||||
|
|
||||||
.material-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-list-item:hover {
|
.material-list-item:hover {
|
||||||
background: var(--ant-color-fill-tertiary);
|
background: var(--ant-color-bg-text-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-list-item--selected {
|
.material-list-item--selected {
|
||||||
border-left-color: var(--ant-color-primary);
|
background: var(--ant-color-primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-list-item--selected:hover {
|
.material-list-item--selected:hover {
|
||||||
background: var(--ant-color-fill-tertiary);
|
background: var(--ant-color-primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-item-right {
|
.material-item-right {
|
||||||
@@ -351,52 +393,6 @@ body {
|
|||||||
opacity: 1;
|
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 {
|
.app-inbox-filter-count {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: var(--ant-font-size-sm);
|
font-size: var(--ant-font-size-sm);
|
||||||
|
|||||||
177
tests/web/components/ChatPage.test.tsx
Normal file
177
tests/web/components/ChatPage.test.tsx
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/web/components/ConversationCard.test.tsx
Normal file
95
tests/web/components/ConversationCard.test.tsx
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
160
tests/web/components/ConversationList.test.tsx
Normal file
160
tests/web/components/ConversationList.test.tsx
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
104
tests/web/components/ConversationSidebar.test.tsx
Normal file
104
tests/web/components/ConversationSidebar.test.tsx
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ const MOCK_MATERIAL: Material = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("MaterialCard", () => {
|
describe("MaterialCard", () => {
|
||||||
test("渲染素材描述、时间和状态标签", () => {
|
test("渲染素材描述和状态标签", () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
createElement(MaterialCard, {
|
createElement(MaterialCard, {
|
||||||
material: MOCK_MATERIAL,
|
material: MOCK_MATERIAL,
|
||||||
@@ -28,7 +28,6 @@ describe("MaterialCard", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
||||||
expect(screen.getByText("今天")).not.toBeNull();
|
|
||||||
expect(screen.getByText("待审核")).not.toBeNull();
|
expect(screen.getByText("待审核")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
104
tests/web/utils/date-group.test.ts
Normal file
104
tests/web/utils/date-group.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user