feat(inbox): 侧边栏状态筛选与日期分组 — Segmented 图标筛选 + Skeleton 加载态 + 五级日期分组可折叠 + 卡片显示关联日期

This commit is contained in:
2026-06-03 17:22:14 +08:00
parent abe30ead6a
commit 1a7fd58553
5 changed files with 223 additions and 31 deletions

View File

@@ -10,6 +10,11 @@ interface MaterialCardProps {
selected: boolean;
}
function formatAssociatedDate(date: string): string {
if (!date) return "—";
return date;
}
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" },
@@ -28,7 +33,7 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
</Typography.Text>
<br />
<Typography.Text className="material-item-time" type="secondary">
{formatMaterialTime(material.createdAt)}
{formatAssociatedDate(material.associatedDate)}
</Typography.Text>
</div>
<div className="material-item-right">
@@ -61,19 +66,3 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
</Flex>
);
}
function formatMaterialTime(timestamp: string, now = new Date()): string {
const time = new Date(timestamp);
const diffMs = now.getTime() - time.getTime();
if (diffMs < 60_000) return "刚刚";
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86_400_000);
const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate());
if (timeDate.getTime() >= today.getTime()) return "今天";
if (timeDate.getTime() >= yesterday.getTime()) return "昨天";
const mm = String(time.getMonth() + 1).padStart(2, "0");
const dd = String(time.getDate()).padStart(2, "0");
return `${time.getFullYear()}-${mm}-${dd}`;
}

View File

@@ -0,0 +1,39 @@
import { CaretDownOutlined, CaretRightOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import { type ReactNode, useState } from "react";
interface MaterialGroupProps {
children: ReactNode;
count: number;
emptyText?: string;
label: string;
}
export function MaterialGroup({ children, count, emptyText, 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">
{count === 0 && emptyText ? (
<Typography.Text className="app-inbox-group-empty" type="secondary">
{emptyText}
</Typography.Text>
) : (
children
)}
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,19 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty, Spin } from "antd";
import {
AppstoreOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { Button, Empty, Segmented, Skeleton } from "antd";
import { useMemo, useState } from "react";
import type { Material } from "../types";
import { MaterialCard } from "./MaterialCard";
import { MaterialGroup } from "./MaterialGroup";
type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
interface MaterialListProps {
loading: boolean;
@@ -14,29 +24,132 @@ 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" },
{ color: "#52c41a", icon: <CheckCircleOutlined />, label: "已通过", value: "approved" },
{ color: "#ff4d4f", icon: <CloseCircleOutlined />, label: "已放弃", value: "discarded" },
] as const;
type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
const filteredMaterials = useMemo(() => {
if (filterStatus === "all") return materials;
return materials.filter((m) => m.status === filterStatus);
}, [materials, filterStatus]);
const groupedMaterials = useMemo(() => groupMaterialsByDate(filteredMaterials), [filteredMaterials]);
const segmentedOptions = useMemo(
() =>
STATUS_FILTER_OPTIONS.map((opt) => ({
label: (
<span>
{"color" in opt && opt.color ? <span style={{ color: opt.color }}>{opt.icon}</span> : opt.icon}
<span className="app-inbox-filter-count">{getStatusCount(materials, opt.value)}</span>
</span>
),
value: opt.value,
})),
[materials],
);
const showAllGroups = filterStatus === "all";
return (
<div className="app-inbox-sidebar">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
<div className="app-inbox-list">
{loading ? (
<Spin />
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : materials.length === 0 ? (
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : filteredMaterials.length === 0 ? (
<Empty description="当前筛选条件下无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
materials.map((material) => (
<MaterialCard
key={material.id}
material={material}
onDelete={() => onDelete(material.id)}
onSelect={() => onSelect(material.id)}
selected={material.id === selectedId}
/>
))
groupedMaterials.map((group) => {
if (!showAllGroups && group.items.length === 0) return null;
return (
<MaterialGroup
count={group.items.length}
emptyText="暂无"
key={group.key}
label={GROUP_LABELS[group.key]}
>
{group.items.map((material) => (
<MaterialCard
key={material.id}
material={material}
onDelete={() => onDelete(material.id)}
onSelect={() => onSelect(material.id)}
selected={material.id === selectedId}
/>
))}
</MaterialGroup>
);
})
)}
</div>
</div>
);
}
function getStatusCount(materials: readonly Material[], status: string): number {
if (status === "all") return materials.length;
return materials.filter((m) => m.status === status).length;
}

View File

@@ -12,7 +12,7 @@ interface MaterialSidebarProps {
}
export function MaterialSidebar({ onAddClick, onDelete, onSelect, projectId, selectedId }: MaterialSidebarProps) {
const { data, error, isLoading, refetch } = useMaterialList(projectId);
const { data, error, isLoading, refetch } = useMaterialList(projectId, { pageSize: 200 });
const deleteMutation = useDeleteMaterial(projectId);
const handleDelete = (id: string) => {

View File

@@ -308,7 +308,7 @@ body {
.material-list-item {
border-left: 3px solid transparent;
border-bottom: 1px solid var(--ant-color-border-secondary);
padding: var(--ant-padding-xs) 0;
padding: var(--ant-padding-xs) var(--ant-padding-sm);
padding-left: var(--ant-padding-sm);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
@@ -365,3 +365,54 @@ body {
.material-item-time {
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-group-empty {
display: block;
padding: var(--ant-padding-xs) var(--ant-padding);
font-size: var(--ant-font-size-sm);
}
.app-inbox-filter-count {
margin-left: 4px;
font-size: var(--ant-font-size-sm);
}