feat(inbox): 侧边栏状态筛选与日期分组 — Segmented 图标筛选 + Skeleton 加载态 + 五级日期分组可折叠 + 卡片显示关联日期
This commit is contained in:
@@ -10,6 +10,11 @@ interface MaterialCardProps {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAssociatedDate(date: string): string {
|
||||||
|
if (!date) return "—";
|
||||||
|
return 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: "已放弃" },
|
||||||
@@ -28,7 +33,7 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<br />
|
<br />
|
||||||
<Typography.Text className="material-item-time" type="secondary">
|
<Typography.Text className="material-item-time" type="secondary">
|
||||||
{formatMaterialTime(material.createdAt)}
|
{formatAssociatedDate(material.associatedDate)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="material-item-right">
|
<div className="material-item-right">
|
||||||
@@ -61,19 +66,3 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
|||||||
</Flex>
|
</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}`;
|
|
||||||
}
|
|
||||||
|
|||||||
39
src/web/features/inbox/components/MaterialGroup.tsx
Normal file
39
src/web/features/inbox/components/MaterialGroup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { PlusOutlined } from "@ant-design/icons";
|
import {
|
||||||
import { Button, Empty, Spin } from "antd";
|
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 type { Material } from "../types";
|
||||||
|
|
||||||
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;
|
||||||
@@ -14,29 +24,132 @@ 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 = [
|
||||||
|
{ 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) {
|
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 (
|
return (
|
||||||
<div className="app-inbox-sidebar">
|
<div className="app-inbox-sidebar">
|
||||||
<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} />
|
||||||
<div className="app-inbox-list">
|
<div className="app-inbox-list">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spin />
|
<Skeleton active paragraph={{ rows: 6 }} title={false} />
|
||||||
) : materials.length === 0 ? (
|
) : materials.length === 0 ? (
|
||||||
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : filteredMaterials.length === 0 ? (
|
||||||
|
<Empty description="当前筛选条件下无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
) : (
|
) : (
|
||||||
materials.map((material) => (
|
groupedMaterials.map((group) => {
|
||||||
<MaterialCard
|
if (!showAllGroups && group.items.length === 0) return null;
|
||||||
key={material.id}
|
return (
|
||||||
material={material}
|
<MaterialGroup
|
||||||
onDelete={() => onDelete(material.id)}
|
count={group.items.length}
|
||||||
onSelect={() => onSelect(material.id)}
|
emptyText="暂无"
|
||||||
selected={material.id === selectedId}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusCount(materials: readonly Material[], status: string): number {
|
||||||
|
if (status === "all") return materials.length;
|
||||||
|
return materials.filter((m) => m.status === status).length;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface MaterialSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialSidebar({ onAddClick, onDelete, onSelect, projectId, selectedId }: 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 deleteMutation = useDeleteMaterial(projectId);
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ body {
|
|||||||
.material-list-item {
|
.material-list-item {
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
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);
|
padding-left: var(--ant-padding-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s ease, background 0.15s ease;
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
@@ -365,3 +365,54 @@ body {
|
|||||||
.material-item-time {
|
.material-item-time {
|
||||||
font-size: var(--ant-font-size-sm);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user