diff --git a/src/web/features/inbox/components/MaterialCard.tsx b/src/web/features/inbox/components/MaterialCard.tsx index 44310d0..4419913 100644 --- a/src/web/features/inbox/components/MaterialCard.tsx +++ b/src/web/features/inbox/components/MaterialCard.tsx @@ -10,6 +10,11 @@ interface MaterialCardProps { selected: boolean; } +function formatAssociatedDate(date: string): string { + if (!date) return "—"; + return date; +} + const STATUS_MAP: Record = { approved: { color: "green", label: "已通过" }, discarded: { color: "red", label: "已放弃" }, @@ -28,7 +33,7 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
- {formatMaterialTime(material.createdAt)} + {formatAssociatedDate(material.associatedDate)}
@@ -61,19 +66,3 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia ); } - -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}`; -} diff --git a/src/web/features/inbox/components/MaterialGroup.tsx b/src/web/features/inbox/components/MaterialGroup.tsx new file mode 100644 index 0000000..8753640 --- /dev/null +++ b/src/web/features/inbox/components/MaterialGroup.tsx @@ -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 ( +
+
setCollapsed(!collapsed)}> + {collapsed ? : } + + {label} + + + ({count}) + +
+ {!collapsed && ( +
+ {count === 0 && emptyText ? ( + + {emptyText} + + ) : ( + children + )} +
+ )} +
+ ); +} diff --git a/src/web/features/inbox/components/MaterialList.tsx b/src/web/features/inbox/components/MaterialList.tsx index 4ce36ed..e9a668d 100644 --- a/src/web/features/inbox/components/MaterialList.tsx +++ b/src/web/features/inbox/components/MaterialList.tsx @@ -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 = { + earlier: "更早", + thisMonth: "本月", + thisWeek: "本周", + today: "今天", + yesterday: "昨天", +}; + +const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"]; + +interface MaterialGroupData { + items: Material[]; + key: DateGroup; +} + +function getDateGroup(dateStr: string, now: Date): DateGroup { + const date = new Date(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86_400_000); + const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + if (dateDay.getTime() >= today.getTime()) return "today"; + if (dateDay.getTime() >= yesterday.getTime()) return "yesterday"; + + const dayOfWeek = today.getDay(); + const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const monday = new Date(today.getTime() - mondayOffset * 86_400_000); + if (dateDay.getTime() >= monday.getTime()) return "thisWeek"; + + if (dateDay.getFullYear() === today.getFullYear() && dateDay.getMonth() === today.getMonth()) { + return "thisMonth"; + } + + return "earlier"; +} + +function groupMaterialsByDate(materials: readonly Material[]): MaterialGroupData[] { + const now = new Date(); + const groups = new Map(); + + for (const m of materials) { + const group = getDateGroup(m.createdAt, now); + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(m); + } + + return GROUP_ORDER.map((key) => ({ items: groups.get(key) ?? [], key })); +} + +const STATUS_FILTER_OPTIONS = [ + { icon: , label: "全部", value: "all" }, + { color: "#faad14", icon: , label: "待审核", value: "pending" }, + { color: "#52c41a", icon: , label: "已通过", value: "approved" }, + { color: "#ff4d4f", icon: , 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("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: ( + + {"color" in opt && opt.color ? {opt.icon} : opt.icon} + {getStatusCount(materials, opt.value)} + + ), + value: opt.value, + })), + [materials], + ); + + const showAllGroups = filterStatus === "all"; + return (
+ setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
{loading ? ( - + ) : materials.length === 0 ? ( + ) : filteredMaterials.length === 0 ? ( + ) : ( - materials.map((material) => ( - onDelete(material.id)} - onSelect={() => onSelect(material.id)} - selected={material.id === selectedId} - /> - )) + groupedMaterials.map((group) => { + if (!showAllGroups && group.items.length === 0) return null; + return ( + + {group.items.map((material) => ( + onDelete(material.id)} + onSelect={() => onSelect(material.id)} + selected={material.id === selectedId} + /> + ))} + + ); + }) )}
); } + +function getStatusCount(materials: readonly Material[], status: string): number { + if (status === "all") return materials.length; + return materials.filter((m) => m.status === status).length; +} diff --git a/src/web/features/inbox/components/MaterialSidebar.tsx b/src/web/features/inbox/components/MaterialSidebar.tsx index 72f76fe..4296b60 100644 --- a/src/web/features/inbox/components/MaterialSidebar.tsx +++ b/src/web/features/inbox/components/MaterialSidebar.tsx @@ -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) => { diff --git a/src/web/styles.css b/src/web/styles.css index 21db406..9b7014f 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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); +}