feat: 用自定义侧边栏替换聊天室 Conversations 组件,提取公共 SidebarGroup 和 date-group
This commit is contained in:
@@ -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 (
|
||||
<div className="app-chat-page">
|
||||
<div className="app-chat-conversations">
|
||||
<div className="app-chat-conversations-header">
|
||||
<Button
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
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"
|
||||
>
|
||||
新对话
|
||||
</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>
|
||||
<ConversationSidebar
|
||||
onAddClick={handleAddConversation}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
onSelect={setActiveConversationId}
|
||||
projectId={project.id}
|
||||
selectedId={activeConversationId}
|
||||
/>
|
||||
<ChatPanel
|
||||
conversationId={activeConversationId}
|
||||
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 { 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<MaterialStatus, { color: string; label: string }> = {
|
||||
approved: { color: "green", label: "已通过" },
|
||||
discarded: { color: "red", label: "已放弃" },
|
||||
@@ -29,15 +22,13 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
||||
|
||||
return (
|
||||
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text ellipsis strong={selected}>
|
||||
{material.description}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text className="material-item-time" type="secondary">
|
||||
{formatAssociatedDate(material.associatedDate)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Paragraph
|
||||
className="material-item-desc"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ flex: 1, margin: 0, minWidth: 0 }}
|
||||
>
|
||||
{material.description}
|
||||
</Typography.Paragraph>
|
||||
<div className="material-item-right">
|
||||
<span className="material-item-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 { 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<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 = [
|
||||
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
||||
{ 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);
|
||||
}, [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 (
|
||||
<div className="app-inbox-sidebar">
|
||||
<div className="app-inbox-sidebar-header">
|
||||
<div className="app-sidebar-list" style={{ width: 260 }}>
|
||||
<div className="app-sidebar-list-header">
|
||||
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
|
||||
新增素材
|
||||
</Button>
|
||||
<Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
|
||||
</div>
|
||||
<OverlayScrollbarsComponent
|
||||
className="app-inbox-list"
|
||||
className="app-sidebar-list-body"
|
||||
options={{
|
||||
overflow: { x: "hidden", y: "scroll" },
|
||||
scrollbars: {
|
||||
@@ -138,7 +88,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
||||
groupedMaterials.map((group) => {
|
||||
if (group.items.length === 0) return null;
|
||||
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) => (
|
||||
<MaterialCard
|
||||
key={material.id}
|
||||
@@ -148,7 +98,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user