unis_crm/frontend/src/pages/Dashboard.tsx

1899 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
ArrowDownRight,
ArrowUpRight,
BadgeDollarSign,
BarChart3,
BriefcaseBusiness,
Building2,
CalendarDays,
ChevronDown,
Check,
CircleDollarSign,
ClipboardList,
DollarSign,
FileText,
HandCoins,
Landmark,
Megaphone,
MoreVertical,
Package,
Paperclip,
PhoneCall,
PieChart,
Rocket,
ShoppingCart,
Store,
Target,
TrendingUp,
Trophy,
Users,
Wallet,
X,
Zap,
} from "lucide-react";
import { motion } from "motion/react";
import { useNavigate } from "react-router-dom";
import {
completeDashboardTodo,
getDashboardAnalyticsCardDetail,
getDashboardHome,
getWorkReportHistoryItem,
readDashboardMessage,
type DashboardActivity,
type DashboardAnalyticsCard,
type DashboardHome,
type DashboardMessage,
type DashboardStat,
type DashboardTodo,
type WorkHistoryItem,
type WorkReportAttachment,
type WorkReportLineItem,
} from "@/lib/auth";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import DashboardAnalyticsChart from "@/components/dashboard/DashboardAnalyticsChart";
import { AttachmentPreviewModal, formatAttachmentSize } from "@/components/AttachmentPreviewModal";
const DASHBOARD_PREVIEW_COUNT = 5;
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
const baseStats = [
{ name: "本月新增商机", metricKey: "monthlyOpportunities", icon: TrendingUp, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-500/20" },
{ name: "本月已签单商机金额", metricKey: "monthlyWonOpportunities", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" },
{ name: "已推送OMS项目", metricKey: "pushedOmsProjects", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20" },
{ name: "本月新增渠道", metricKey: "monthlyChannels", icon: Building2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20" },
] as const;
const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: string; state?: { tab: "sales" | "channel" } }> = {
monthlyOpportunities: { pathname: "/opportunities" },
pushedOmsProjects: { pathname: "/opportunities" },
monthlyChannels: { pathname: "/expansion", state: { tab: "channel" } },
monthlyWonOpportunities: { pathname: "/opportunities" },
};
const amountMetricKeys = new Set<(typeof baseStats)[number]["metricKey"]>([
"monthlyOpportunities",
"pushedOmsProjects",
"monthlyWonOpportunities",
]);
type AnalyticsCardDisplayConfig = Partial<{
horizontalColumns: number;
metricIconKey: string;
metricStylePreset: "classic" | "gradient" | "contrast" | "soft";
metricTheme: "blue" | "green" | "orange" | "purple" | "red" | "slate";
metricIconShape: "rounded-square" | "circle" | "pill";
metricTrendMode: "auto" | "up" | "down" | "neutral";
metricBadgeText: string;
dimensionSwitchEnabled: boolean;
defaultDimension: string;
dimensionOptions: Array<{ value: string; label?: string }>;
pcLayout: {
preset?: "auto" | "left" | "right" | "top" | "bottom" | "center";
chartWidthPercent?: number;
chartHeight?: number;
};
mobileLayout: {
preset?: "auto" | "left" | "right" | "top" | "bottom" | "center";
chartWidthPercent?: number;
chartHeight?: number;
};
}>;
type AnalyticsDimensionOption = {
value: string;
label: string;
};
type MetricIconComponent =
| typeof Activity
| typeof BadgeDollarSign
| typeof BarChart3
| typeof BriefcaseBusiness
| typeof Building2
| typeof CalendarDays
| typeof CircleDollarSign
| typeof ClipboardList
| typeof DollarSign
| typeof FileText
| typeof HandCoins
| typeof Landmark
| typeof Megaphone
| typeof Package
| typeof PhoneCall
| typeof PieChart
| typeof Rocket
| typeof ShoppingCart
| typeof Store
| typeof Target
| typeof TrendingUp
| typeof Trophy
| typeof Users
| typeof Wallet
| typeof Zap;
type MetricVisual = {
key: string;
icon: MetricIconComponent;
iconClassName: string;
backgroundClassName: string;
};
type GroupedDashboardCard<T extends { groupName?: string }> = {
key: string;
title: string;
isDefault: boolean;
cards: T[];
};
type AnalyticsGroupTab = {
key: string;
title: string;
};
const ANALYTICS_ALL_TAB_KEY = "__all__";
const DASHBOARD_ANALYTICS_TAB_STORAGE_KEY = "crm-dashboard-analytics-tab";
function formatStatDisplay(metricKey: (typeof baseStats)[number]["metricKey"], value?: number) {
const numericValue = value ?? 0;
if (!amountMetricKeys.has(metricKey)) {
return {
value: String(numericValue),
unit: "个",
};
}
return {
value: numericValue.toFixed(2).replace(/\.?0+$/, ""),
unit: "万元",
};
}
function getStatValueClass(valueText: string) {
const length = valueText.length;
if (length >= 8) {
return "text-[17px] min-[380px]:text-[18px] min-[430px]:text-[20px] min-[520px]:text-[28px]";
}
if (length >= 7) {
return "text-[18px] min-[380px]:text-[20px] min-[430px]:text-[22px] min-[520px]:text-[30px]";
}
return "text-[20px] min-[380px]:text-[22px] min-[430px]:text-[24px] min-[520px]:text-[34px]";
}
function isChartAnalyticsCard(card: DashboardAnalyticsCard) {
return card.renderType !== undefined && card.renderType !== "metric";
}
function supportsAnalyticsDetail(card: DashboardAnalyticsCard) {
return Boolean(card?.hasMore) && card.renderType !== "metric";
}
function getAnalyticsCardPreviewSummary(card: DashboardAnalyticsCard) {
const visibleCount = card.chartData?.length ?? 0;
const totalCount = card.totalCount ?? visibleCount;
if (!card.hasMore || totalCount <= visibleCount) {
return "";
}
return `已展示 ${visibleCount} / 共 ${totalCount}`;
}
function parseAnalyticsDisplayConfig(raw?: string): AnalyticsCardDisplayConfig {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return {};
}
const config = parsed as Record<string, unknown>;
const normalizeDimensionOptions = (value: unknown): AnalyticsDimensionOption[] => {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => {
if (typeof item === "string") {
const normalized = item.trim();
return normalized ? { value: normalized, label: normalized } : null;
}
if (!item || typeof item !== "object") {
return null;
}
const option = item as Record<string, unknown>;
const optionValue = typeof option.value === "string" ? option.value.trim() : "";
if (!optionValue) {
return null;
}
const optionLabel = typeof option.label === "string" ? option.label.trim() : optionValue;
return { value: optionValue, label: optionLabel };
})
.filter(Boolean) as AnalyticsDimensionOption[];
};
return {
...(config as AnalyticsCardDisplayConfig),
dimensionSwitchEnabled: Boolean(config.dimensionSwitchEnabled),
defaultDimension: typeof config.defaultDimension === "string" ? config.defaultDimension : undefined,
dimensionOptions: normalizeDimensionOptions(config.dimensionOptions),
pcLayout: config.pcLayout && typeof config.pcLayout === "object" ? config.pcLayout as AnalyticsCardDisplayConfig["pcLayout"] : undefined,
mobileLayout: config.mobileLayout && typeof config.mobileLayout === "object" ? config.mobileLayout as AnalyticsCardDisplayConfig["mobileLayout"] : undefined,
};
} catch {
return {};
}
}
function getAnalyticsDimensionOptions(card: DashboardAnalyticsCard): AnalyticsDimensionOption[] {
const config = parseAnalyticsDisplayConfig(card.displayTextConfig);
if (!config.dimensionSwitchEnabled) {
return [];
}
return (config.dimensionOptions || []).filter((item): item is AnalyticsDimensionOption => Boolean(item?.value));
}
function getAnalyticsDefaultDimension(card: DashboardAnalyticsCard) {
const config = parseAnalyticsDisplayConfig(card.displayTextConfig);
const options = getAnalyticsDimensionOptions(card);
const configuredDefault = config.defaultDimension?.trim();
if (configuredDefault && options.some((item) => item.value === configuredDefault)) {
return configuredDefault;
}
return options[0]?.value || "";
}
function resolveHorizontalColumns(card: DashboardAnalyticsCard) {
const config = parseAnalyticsDisplayConfig(card.displayTextConfig);
const horizontalColumns = Number(config.horizontalColumns);
if (Number.isFinite(horizontalColumns)) {
return Math.max(1, Math.min(4, horizontalColumns));
}
return 2;
}
const METRIC_ICON_LIBRARY: Array<{
key: string;
icon: MetricIconComponent;
iconClassName: string;
backgroundClassName: string;
}> = [
{ key: "dollar-sign", icon: DollarSign, iconClassName: "text-blue-600", backgroundClassName: "bg-blue-50" },
{ key: "circle-dollar-sign", icon: CircleDollarSign, iconClassName: "text-cyan-600", backgroundClassName: "bg-cyan-50" },
{ key: "badge-dollar-sign", icon: BadgeDollarSign, iconClassName: "text-indigo-600", backgroundClassName: "bg-indigo-50" },
{ key: "hand-coins", icon: HandCoins, iconClassName: "text-teal-600", backgroundClassName: "bg-teal-50" },
{ key: "wallet", icon: Wallet, iconClassName: "text-blue-600", backgroundClassName: "bg-blue-50" },
{ key: "shopping-cart", icon: ShoppingCart, iconClassName: "text-amber-600", backgroundClassName: "bg-amber-50" },
{ key: "store", icon: Store, iconClassName: "text-orange-600", backgroundClassName: "bg-orange-50" },
{ key: "package", icon: Package, iconClassName: "text-yellow-600", backgroundClassName: "bg-yellow-50" },
{ key: "users", icon: Users, iconClassName: "text-purple-600", backgroundClassName: "bg-purple-50" },
{ key: "building-2", icon: Building2, iconClassName: "text-violet-600", backgroundClassName: "bg-violet-50" },
{ key: "trending-up", icon: TrendingUp, iconClassName: "text-emerald-600", backgroundClassName: "bg-emerald-50" },
{ key: "bar-chart-3", icon: BarChart3, iconClassName: "text-sky-600", backgroundClassName: "bg-sky-50" },
{ key: "pie-chart", icon: PieChart, iconClassName: "text-fuchsia-600", backgroundClassName: "bg-fuchsia-50" },
{ key: "target", icon: Target, iconClassName: "text-rose-600", backgroundClassName: "bg-rose-50" },
{ key: "trophy", icon: Trophy, iconClassName: "text-yellow-700", backgroundClassName: "bg-yellow-50" },
{ key: "briefcase-business", icon: BriefcaseBusiness, iconClassName: "text-slate-600", backgroundClassName: "bg-slate-100" },
{ key: "landmark", icon: Landmark, iconClassName: "text-stone-600", backgroundClassName: "bg-stone-100" },
{ key: "megaphone", icon: Megaphone, iconClassName: "text-pink-600", backgroundClassName: "bg-pink-50" },
{ key: "phone-call", icon: PhoneCall, iconClassName: "text-green-600", backgroundClassName: "bg-green-50" },
{ key: "clipboard-list", icon: ClipboardList, iconClassName: "text-slate-600", backgroundClassName: "bg-slate-100" },
{ key: "file-text", icon: FileText, iconClassName: "text-zinc-600", backgroundClassName: "bg-zinc-100" },
{ key: "calendar-days", icon: CalendarDays, iconClassName: "text-red-600", backgroundClassName: "bg-red-50" },
{ key: "activity", icon: Activity, iconClassName: "text-lime-600", backgroundClassName: "bg-lime-50" },
{ key: "zap", icon: Zap, iconClassName: "text-yellow-600", backgroundClassName: "bg-yellow-50" },
{ key: "rocket", icon: Rocket, iconClassName: "text-violet-600", backgroundClassName: "bg-violet-50" },
];
const METRIC_ICON_VISUALS: Record<string, MetricVisual> = Object.fromEntries(
METRIC_ICON_LIBRARY.map((item) => [item.key, item] as const)
);
const LEGACY_METRIC_ICON_KEY_MAP: Record<string, string> = {
revenue: "dollar-sign",
customers: "users",
orders: "shopping-cart",
growth: "trending-up",
analytics: "bar-chart-3",
channel: "building-2",
};
const METRIC_THEME_COLORS = {
blue: { accent: "#2563eb", soft: "#dbeafe", pale: "#eff6ff", dark: "#1d4ed8" },
green: { accent: "#059669", soft: "#a7f3d0", pale: "#ecfdf5", dark: "#047857" },
orange: { accent: "#ea580c", soft: "#fdba74", pale: "#fff7ed", dark: "#c2410c" },
purple: { accent: "#7c3aed", soft: "#c4b5fd", pale: "#f5f3ff", dark: "#6d28d9" },
red: { accent: "#dc2626", soft: "#fca5a5", pale: "#fef2f2", dark: "#b91c1c" },
slate: { accent: "#475569", soft: "#cbd5e1", pale: "#f8fafc", dark: "#334155" },
} as const;
function hexToRgba(hex: string, alpha: number) {
const normalized = hex.replace("#", "");
const full = normalized.length === 3
? normalized.split("").map((char) => `${char}${char}`).join("")
: normalized;
const value = Number.parseInt(full, 16);
if (!Number.isFinite(value)) {
return `rgba(37, 99, 235, ${alpha})`;
}
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function resolveConfiguredMetricIconKey(metricIconKey?: string) {
if (!metricIconKey) {
return "";
}
if (METRIC_ICON_VISUALS[metricIconKey]) {
return metricIconKey;
}
return LEGACY_METRIC_ICON_KEY_MAP[metricIconKey] || "";
}
function resolveMetricThemeKey(config: AnalyticsCardDisplayConfig, iconKey?: string): keyof typeof METRIC_THEME_COLORS {
if (config.metricTheme && config.metricTheme in METRIC_THEME_COLORS) {
return config.metricTheme;
}
if (iconKey && ["shopping-cart", "store", "package", "trophy"].includes(iconKey)) {
return "orange";
}
if (iconKey && ["users", "building-2", "rocket", "pie-chart"].includes(iconKey)) {
return "purple";
}
if (iconKey && ["trending-up", "activity", "phone-call", "hand-coins"].includes(iconKey)) {
return "green";
}
if (iconKey && ["calendar-days", "target", "megaphone"].includes(iconKey)) {
return "red";
}
if (iconKey && ["briefcase-business", "file-text", "landmark", "clipboard-list"].includes(iconKey)) {
return "slate";
}
return "blue";
}
function getMetricCardStyles(config: AnalyticsCardDisplayConfig, iconKey?: string) {
const theme = METRIC_THEME_COLORS[resolveMetricThemeKey(config, iconKey)];
const preset = config.metricStylePreset || "classic";
const iconShape = config.metricIconShape || "rounded-square";
const iconRadius = iconShape === "circle" ? 999 : iconShape === "pill" ? 24 : 18;
if (preset === "contrast") {
return {
container: {
border: `1px solid ${hexToRgba("#ffffff", 0.08)}`,
background: `linear-gradient(135deg, #0f172a 0%, ${theme.dark} 100%)`,
boxShadow: `0 16px 36px ${hexToRgba(theme.accent, 0.2)}`,
},
iconShell: {
background: hexToRgba("#ffffff", 0.14),
borderRadius: iconRadius,
boxShadow: `inset 0 0 0 1px ${hexToRgba("#ffffff", 0.1)}`,
},
iconColor: "#ffffff",
titleColor: hexToRgba("#ffffff", 0.72),
valueColor: "#ffffff",
metaColor: hexToRgba("#ffffff", 0.84),
badgeStyle: {
background: hexToRgba("#ffffff", 0.12),
color: "#ffffff",
border: `1px solid ${hexToRgba("#ffffff", 0.16)}`,
},
};
}
if (preset === "gradient") {
return {
container: {
border: `1px solid ${hexToRgba(theme.soft, 0.42)}`,
background: `linear-gradient(135deg, ${hexToRgba(theme.accent, 0.14)} 0%, #ffffff 52%, ${hexToRgba(theme.soft, 0.62)} 100%)`,
boxShadow: `0 12px 28px ${hexToRgba(theme.accent, 0.08)}`,
},
iconShell: {
background: `linear-gradient(135deg, ${theme.accent} 0%, ${theme.dark} 100%)`,
borderRadius: iconRadius,
boxShadow: `0 14px 28px ${hexToRgba(theme.accent, 0.22)}`,
},
iconColor: "#ffffff",
titleColor: theme.dark,
valueColor: "#0f172a",
metaColor: "#475569",
badgeStyle: {
background: hexToRgba(theme.accent, 0.1),
color: theme.dark,
border: `1px solid ${hexToRgba(theme.accent, 0.16)}`,
},
};
}
if (preset === "soft") {
return {
container: {
border: `1px solid ${hexToRgba(theme.accent, 0.12)}`,
background: `radial-gradient(circle at top right, ${hexToRgba(theme.soft, 0.68)} 0%, #ffffff 58%)`,
boxShadow: `0 12px 28px ${hexToRgba(theme.accent, 0.06)}`,
},
iconShell: {
background: hexToRgba(theme.accent, 0.12),
borderRadius: iconRadius,
},
iconColor: theme.accent,
titleColor: "#64748b",
valueColor: "#0f172a",
metaColor: "#64748b",
badgeStyle: {
background: hexToRgba(theme.accent, 0.08),
color: theme.dark,
border: `1px solid ${hexToRgba(theme.accent, 0.14)}`,
},
};
}
return {
container: {
border: `1px solid ${hexToRgba(theme.accent, 0.12)}`,
background: `linear-gradient(180deg, #ffffff 0%, ${theme.pale} 100%)`,
boxShadow: `0 12px 28px ${hexToRgba(theme.accent, 0.06)}`,
},
iconShell: {
background: hexToRgba(theme.accent, 0.12),
borderRadius: iconRadius,
},
iconColor: theme.accent,
titleColor: "#64748b",
valueColor: "#0f172a",
metaColor: "#475569",
badgeStyle: {
background: hexToRgba(theme.accent, 0.08),
color: theme.dark,
border: `1px solid ${hexToRgba(theme.accent, 0.14)}`,
},
};
}
function getAnalyticsCardLayoutClass(card: DashboardAnalyticsCard, isMobileViewport: boolean) {
if (card.fullRow) {
return "col-span-12";
}
if (isMobileViewport && isChartAnalyticsCard(card)) {
return "col-span-12";
}
if (card.layoutType !== "horizontal") {
return "col-span-12";
}
if (isMobileViewport) {
switch (resolveHorizontalColumns(card)) {
case 1:
return "col-span-12";
case 3:
return "col-span-4";
case 4:
return "col-span-3";
case 2:
default:
return "col-span-6";
}
}
switch (resolveHorizontalColumns(card)) {
case 1:
return "col-span-12";
case 3:
return "col-span-6 min-[420px]:col-span-4";
case 4:
return "col-span-6 min-[420px]:col-span-4 min-[520px]:col-span-3";
case 2:
default:
return "col-span-6";
}
}
function getAnalyticsMetricVisual(card: DashboardAnalyticsCard, index: number) {
const configuredIconKey = resolveConfiguredMetricIconKey(parseAnalyticsDisplayConfig(card.displayTextConfig).metricIconKey);
if (configuredIconKey && METRIC_ICON_VISUALS[configuredIconKey]) {
return METRIC_ICON_VISUALS[configuredIconKey];
}
const text = `${card.title || ""}${card.subtitle || ""}${card.description || ""}`.toLowerCase();
const visualOptions = [
METRIC_ICON_VISUALS["dollar-sign"],
METRIC_ICON_VISUALS.users,
METRIC_ICON_VISUALS["shopping-cart"],
METRIC_ICON_VISUALS["trending-up"],
] as const;
if (text.includes("金额") || text.includes("营收") || text.includes("收入") || text.includes("业绩") || text.includes("回款") || text.includes("销售额")) {
return METRIC_ICON_VISUALS["dollar-sign"];
}
if (text.includes("用户") || text.includes("客户") || text.includes("成员") || text.includes("人员")) {
return METRIC_ICON_VISUALS.users;
}
if (text.includes("订单") || text.includes("签约") || text.includes("成交") || text.includes("商机")) {
return METRIC_ICON_VISUALS["shopping-cart"];
}
if (text.includes("渠道") || text.includes("组织") || text.includes("区域") || text.includes("网点")) {
return METRIC_ICON_VISUALS["building-2"];
}
if (text.includes("分析") || text.includes("排行") || text.includes("报表") || text.includes("统计")) {
return METRIC_ICON_VISUALS["bar-chart-3"];
}
if (text.includes("达成") || text.includes("增长") || text.includes("转化") || text.includes("完成率") || text.includes("效率")) {
return METRIC_ICON_VISUALS["trending-up"];
}
return visualOptions[index % visualOptions.length];
}
function getAnalyticsMetricFootnote(card: DashboardAnalyticsCard, config: AnalyticsCardDisplayConfig) {
const text = card.description?.trim() || card.subtitle?.trim() || "";
const normalized = text.toLowerCase();
const numericMatch = normalized.match(/[+-]?\d+(?:\.\d+)?%?/);
const numericValue = numericMatch ? Number.parseFloat(numericMatch[0].replace("%", "")) : Number.NaN;
const positive = normalized.includes("+")
|| normalized.includes("增长")
|| normalized.includes("提升")
|| normalized.includes("上升")
|| (Number.isFinite(numericValue) && numericValue > 0);
const negative = normalized.includes("-")
|| normalized.includes("下降")
|| normalized.includes("下滑")
|| normalized.includes("减少")
|| (Number.isFinite(numericValue) && numericValue < 0);
const autoTone = positive ? "up" : negative ? "down" : "neutral";
const tone = config.metricTrendMode && config.metricTrendMode !== "auto" ? config.metricTrendMode : autoTone;
const fallbackText = tone === "up"
? "较上周期保持增长"
: tone === "down"
? "较上周期小幅回落"
: "趋势保持平稳";
if (!text && config.metricTrendMode === "auto") {
return null;
}
return {
text: text || fallbackText,
tone,
} as const;
}
function groupDashboardCards<T extends { groupName?: string }>(cards: T[]): GroupedDashboardCard<T>[] {
const grouped = new Map<string, GroupedDashboardCard<T>>();
cards.forEach((card) => {
const groupName = card.groupName?.trim() || "";
const groupKey = groupName || "__default__";
const current = grouped.get(groupKey);
if (current) {
current.cards.push(card);
return;
}
grouped.set(groupKey, {
key: groupKey,
title: groupName,
isDefault: !groupName,
cards: [card],
});
});
return Array.from(grouped.values());
}
function buildAnalyticsGroupTabs<T extends { groupName?: string }>(sections: GroupedDashboardCard<T>[]) {
if (sections.length <= 1) {
return [] as AnalyticsGroupTab[];
}
return [
{ key: ANALYTICS_ALL_TAB_KEY, title: "全部" },
...sections.map((section) => ({
key: section.key,
title: section.title || "未分组",
})),
];
}
function resolveMobileMetricScale(card: DashboardAnalyticsCard, isMobileViewport: boolean) {
if (!isMobileViewport || card.layoutType !== "horizontal" || card.fullRow || isChartAnalyticsCard(card)) {
return 1;
}
const columns = resolveHorizontalColumns(card);
if (columns <= 2) {
return 1;
}
return Number((2 / columns).toFixed(4));
}
function resolveMobileMetricCompactLevel(scale: number) {
if (scale <= 0.68) {
return 2;
}
if (scale < 1) {
return 1;
}
return 0;
}
export default function Dashboard() {
const navigate = useNavigate();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
const [home, setHome] = useState<DashboardHome>({});
const [loading, setLoading] = useState(true);
const [showAllActivities, setShowAllActivities] = useState(false);
const [showAllHistoryTodos, setShowAllHistoryTodos] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [messageHistoryExpanded, setMessageHistoryExpanded] = useState(false);
const [activeWorkTab, setActiveWorkTab] = useState<"todo" | "message">("todo");
const [completingTodoId, setCompletingTodoId] = useState<string | null>(null);
const [readingMessageId, setReadingMessageId] = useState<number | null>(null);
const [messageDetailMessage, setMessageDetailMessage] = useState<DashboardMessage | null>(null);
const [messageDetailItem, setMessageDetailItem] = useState<WorkHistoryItem | null>(null);
const [messageDetailLoading, setMessageDetailLoading] = useState(false);
const [messageDetailError, setMessageDetailError] = useState("");
const [previewAttachment, setPreviewAttachment] = useState<WorkReportAttachment | null>(null);
const [detailCard, setDetailCard] = useState<DashboardAnalyticsCard | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string>("");
const [analyticsCardOverrides, setAnalyticsCardOverrides] = useState<Record<string, DashboardAnalyticsCard>>({});
const [analyticsSelectedDimensions, setAnalyticsSelectedDimensions] = useState<Record<string, string>>({});
const [analyticsDimensionLoadingKey, setAnalyticsDimensionLoadingKey] = useState<string>("");
const [openDimensionCardKey, setOpenDimensionCardKey] = useState<string | null>(null);
const dimensionMenuRef = useRef<HTMLDivElement | null>(null);
const [activeAnalyticsTab, setActiveAnalyticsTab] = useState<string>(() => {
if (typeof window === "undefined") {
return ANALYTICS_ALL_TAB_KEY;
}
return window.localStorage.getItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY) || ANALYTICS_ALL_TAB_KEY;
});
useEffect(() => {
let cancelled = false;
async function loadDashboard() {
try {
const data = await getDashboardHome();
if (!cancelled) {
setHome(data ?? {});
}
} catch {
if (!cancelled) {
setHome({});
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadDashboard();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
setShowAllActivities(false);
setShowAllHistoryTodos(false);
setHistoryExpanded(false);
setMessageHistoryExpanded(false);
}, [home.activities, home.todos, home.messages]);
const statMap = new Map<string, number | undefined>((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
const stats = baseStats.map((stat) => ({
...stat,
value: statMap.get(stat.metricKey),
}));
const pendingTodos = useMemo(
() => (home.todos ?? []).filter((item) => item.status !== "done"),
[home.todos],
);
const historyTodos = useMemo(
() => (home.todos ?? []).filter((item) => item.status === "done"),
[home.todos],
);
const messages = home.messages ?? [];
const unreadMessages = useMemo(
() => messages.filter((item) => !item.readAt),
[messages],
);
const readMessages = useMemo(
() => messages.filter((item) => item.readAt),
[messages],
);
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
const activities = (home.activities?.length
? home.activities
: [{ id: "0", title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== "0";
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
const showStatsCard = home.statsCardVisible !== false;
const showTodoCard = home.todoCardVisible !== false;
const showActivityCard = home.activityCardVisible !== false;
const showAnalyticsCard = home.analyticsCardVisible !== false && home.analyticsPanel?.enabled === true;
const showWorkCard = showTodoCard || messages.length > 0;
const activeWorkPanel = showTodoCard ? activeWorkTab : "message";
const analyticsCards = useMemo(
() => (home.analyticsPanel?.cards ?? [])
.filter((item) => !item.errorMessage)
.map((item) => {
const override = item.cardKey ? analyticsCardOverrides[item.cardKey] : undefined;
return override ? { ...item, ...override } : item;
}),
[analyticsCardOverrides, home.analyticsPanel?.cards],
);
const analyticsSections = useMemo(
() => groupDashboardCards(analyticsCards),
[analyticsCards],
);
const analyticsTabs = useMemo(
() => buildAnalyticsGroupTabs(analyticsSections),
[analyticsSections],
);
const visibleAnalyticsSections = useMemo(
() => (
activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY || !analyticsTabs.length
? analyticsSections
: analyticsSections.filter((section) => section.key === activeAnalyticsTab)
),
[activeAnalyticsTab, analyticsSections, analyticsTabs.length],
);
useEffect(() => {
if (!analyticsTabs.length) {
if (activeAnalyticsTab !== ANALYTICS_ALL_TAB_KEY) {
setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY);
}
return;
}
if (!analyticsTabs.some((item) => item.key === activeAnalyticsTab)) {
setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY);
}
}, [activeAnalyticsTab, analyticsTabs]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY, activeAnalyticsTab);
}, [activeAnalyticsTab]);
useEffect(() => {
if (!openDimensionCardKey) {
return undefined;
}
const handlePointerDown = (event: MouseEvent) => {
if (dimensionMenuRef.current?.contains(event.target as Node)) {
return;
}
setOpenDimensionCardKey(null);
};
window.addEventListener("mousedown", handlePointerDown);
return () => window.removeEventListener("mousedown", handlePointerDown);
}, [openDimensionCardKey]);
const handleCompleteTodo = async (todoId: string) => {
if (!todoId || completingTodoId === todoId) {
return;
}
setCompletingTodoId(todoId);
try {
await completeDashboardTodo(todoId);
setHome((current) => ({
...current,
todos: (current.todos ?? []).map((item) => (
item.id === todoId
? { ...item, status: "done", updatedAt: new Date().toISOString() }
: item
)),
}));
} catch {
// Keep current UI state when completion fails silently.
} finally {
setCompletingTodoId(null);
}
};
const handleMessageClick = async (message: DashboardMessage) => {
if (!message?.id || readingMessageId === message.id) {
return;
}
setReadingMessageId(message.id);
setMessageDetailMessage(message);
setMessageDetailItem(null);
setMessageDetailError("");
setMessageDetailLoading(true);
try {
if (!message.readAt) {
const readAt = new Date().toISOString();
try {
await readDashboardMessage(message.id);
const nextMessage = { ...message, readAt };
setHome((current) => ({
...current,
messages: (current.messages ?? []).map((item) => (
item.id === message.id ? { ...item, readAt } : item
)),
}));
setMessageDetailMessage(nextMessage);
} catch {
// Keep opening the detail when the read-state update fails, but leave it unread.
}
}
if (message.reportId) {
const item = await getWorkReportHistoryItem(message.reportId);
setMessageDetailItem(item);
} else {
setMessageDetailError("未找到关联日报");
}
} catch (error) {
setMessageDetailError(error instanceof Error ? error.message : "日报详情加载失败");
} finally {
setReadingMessageId(null);
setMessageDetailLoading(false);
}
};
const closeMessageDetail = () => {
setMessageDetailMessage(null);
setMessageDetailItem(null);
setMessageDetailLoading(false);
setMessageDetailError("");
};
const messageDetailContent = messageDetailItem?.content || messageDetailMessage?.content || "";
const messageDetailDate = messageDetailItem?.date || messageDetailMessage?.reportDate || "";
const messageDetailTime = messageDetailItem?.time || messageDetailMessage?.timeText || "";
const messageDetailLineIndexSet = getDashboardMessageLineIndexSet(messageDetailMessage);
const messageDetailLineItems = (messageDetailItem?.lineItems ?? []).filter((_, lineIndex) => (
messageDetailLineIndexSet.size > 0 && messageDetailLineIndexSet.has(lineIndex)
));
const hasMessageDetailLineItems = messageDetailLineItems.length > 0;
const messageDetailAttachments = messageDetailItem?.attachments ?? [];
const handleStatCardClick = (metricKey: (typeof baseStats)[number]["metricKey"]) => {
const target = statRoutes[metricKey];
if (!target) {
return;
}
navigate(target.pathname, target.state ? { state: target.state } : undefined);
};
const handleActivityClick = (activity: DashboardActivity) => {
if (!activity?.bizId || !activity.bizType) {
return;
}
if (activity.bizType === "opportunity") {
const archiveTab = activity.targetTab === "archived" ? "archived" : "active";
navigate("/opportunities", { state: { selectedId: activity.bizId, archiveTab } });
return;
}
if (activity.bizType === "sales" || activity.bizType === "channel") {
const tab = activity.targetTab === "channel" ? "channel" : "sales";
navigate("/expansion", { state: { tab, selectedId: activity.bizId } });
}
};
const handleAnalyticsCardClick = (card: DashboardAnalyticsCard) => {
if (!supportsAnalyticsDetail(card)) {
return;
}
void openAnalyticsCardDetail(card);
};
const handleAnalyticsDimensionChange = async (card: DashboardAnalyticsCard, dimension: string) => {
if (!card?.cardKey || !dimension || analyticsDimensionLoadingKey === card.cardKey) {
return;
}
setOpenDimensionCardKey(null);
setAnalyticsDimensionLoadingKey(card.cardKey);
setAnalyticsSelectedDimensions((current) => ({
...current,
[card.cardKey as string]: dimension,
}));
try {
const data = await getDashboardAnalyticsCardDetail(card.cardKey, dimension);
setAnalyticsCardOverrides((current) => ({
...current,
[card.cardKey as string]: data,
}));
if (detailCard?.cardKey === card.cardKey) {
setDetailCard(data);
}
} catch {
setAnalyticsSelectedDimensions((current) => {
const next = { ...current };
delete next[card.cardKey as string];
return next;
});
} finally {
setAnalyticsDimensionLoadingKey("");
}
};
const openAnalyticsCardDetail = async (card: DashboardAnalyticsCard) => {
if (!card?.cardKey || !supportsAnalyticsDetail(card)) {
return;
}
const selectedDimension = analyticsSelectedDimensions[card.cardKey] || getAnalyticsDefaultDimension(card);
setDetailLoading(true);
setDetailError("");
setDetailCard({
...card,
chartData: [],
});
try {
const data = await getDashboardAnalyticsCardDetail(card.cardKey, selectedDimension || undefined);
setDetailCard(data);
} catch (error) {
setDetailError(error instanceof Error ? error.message : "加载完整卡片详情失败");
} finally {
setDetailLoading(false);
}
};
const closeAnalyticsDetail = () => {
setDetailCard(null);
setDetailError("");
setDetailLoading(false);
};
const dashboardPanelGridClassName = "grid-cols-1";
return (
<div className="crm-page-stack">
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle">
{home.realName || "无"}
</p>
</div>
</header>
{loading ? (
<motion.div
key="dashboard-skeleton"
initial={disableMobileMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.22, ease: "easeOut" }}
>
<DashboardSkeleton />
</motion.div>
) : (
<motion.div
key="dashboard-content"
initial={disableMobileMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
>
{showStatsCard ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-4 min-[520px]:grid-cols-2 sm:gap-4 xl:grid-cols-4">
{stats.map((stat, i) => {
const display = formatStatDisplay(stat.metricKey, stat.value);
const valueClassName = getStatValueClass(display.value);
return (
<motion.button
key={stat.name}
type="button"
onClick={() => handleStatCardClick(stat.metricKey)}
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
className="crm-card group min-h-[104px] overflow-hidden rounded-xl border border-slate-200/80 bg-white/95 p-3 text-left shadow-[0_14px_40px_-28px_rgba(15,23,42,0.35)] transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_44px_-24px_rgba(109,40,217,0.22)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 min-[520px]:min-h-[128px] sm:rounded-2xl sm:p-5"
whileTap={disableMobileMotion ? undefined : { scale: 0.98 }}
aria-label={`查看${stat.name}`}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-violet-200/70 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className="flex h-full flex-col justify-between gap-3 min-[520px]:gap-4">
<div className="flex items-start gap-3 sm:gap-4">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${stat.bg} shadow-inner shadow-white/60 min-[400px]:h-10 min-[400px]:w-10 min-[520px]:h-12 min-[520px]:w-12`}>
<stat.icon className={`h-4.5 w-4.5 ${stat.color} min-[400px]:h-5 min-[400px]:w-5 min-[520px]:h-6 min-[520px]:w-6`} />
</div>
<div className="min-w-0 pt-0.5">
<p className="max-h-10 overflow-hidden text-[11px] font-semibold leading-5 text-slate-500 dark:text-slate-400 min-[400px]:text-xs min-[520px]:text-sm">
{stat.name}
</p>
</div>
</div>
<div className="flex items-end justify-between gap-2 min-[520px]:gap-3">
<div className="min-w-0 flex-1 overflow-hidden">
<p className={`overflow-hidden text-ellipsis whitespace-nowrap font-bold leading-none tracking-[-0.05em] text-slate-900 dark:text-white ${valueClassName}`}>
{display.value}
</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-1 text-[10px] font-medium tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-300 min-[430px]:px-2 min-[520px]:px-2.5 min-[520px]:text-xs">
{display.unit}
</span>
</div>
</div>
</motion.button>
);
})}
</div>
) : null}
<div className={`${showStatsCard ? "mt-5 md:mt-6 " : ""}grid gap-5 ${dashboardPanelGridClassName} md:gap-6`}>
{showWorkCard ? (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.4 }}
className="overflow-hidden rounded-[30px] border border-slate-100 bg-white p-5 shadow-[0_16px_38px_-30px_rgba(15,23,42,0.14)]"
>
<div className="crm-dashboard-panel-header">
<div className="crm-dashboard-panel-heading">
<h2 className="crm-dashboard-panel-title"></h2>
<p className="crm-dashboard-panel-subtitle"></p>
</div>
<span className="crm-dashboard-panel-meta">
{pendingTodos.length + historyTodos.length} · {messages.length}
</span>
</div>
{showTodoCard ? (
<div className="mb-4 grid grid-cols-2 gap-1 rounded-2xl bg-slate-100 p-1">
<button
type="button"
onClick={() => setActiveWorkTab("todo")}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
activeWorkTab === "todo"
? "bg-white text-violet-600 shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
aria-pressed={activeWorkTab === "todo"}
>
</button>
<button
type="button"
onClick={() => setActiveWorkTab("message")}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
activeWorkTab === "message"
? "bg-white text-violet-600 shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
aria-pressed={activeWorkTab === "message"}
>
</button>
</div>
) : null}
{activeWorkPanel === "todo" ? (
<div className="crm-section-stack sm:gap-5">
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
<div className="mb-2 flex items-center gap-2 sm:mb-3">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{pendingTodos.length} </span>
</div>
{pendingTodos.length ? (
<ul className="space-y-2.5 sm:space-y-3">
{pendingTodos.map((task: DashboardTodo) => (
<li key={task.id} className="group flex items-start gap-3 rounded-[20px] border border-slate-100 bg-white px-4 py-3 transition-all hover:border-slate-200 hover:bg-slate-50/70">
<button
type="button"
onClick={() => void handleCompleteTodo(task.id)}
disabled={completingTodoId === task.id}
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-slate-300 text-transparent transition-colors hover:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={`完成待办:${getTodoDisplayTitle(task)}`}
>
<Check className="h-3 w-3" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-700 transition-colors group-hover:text-slate-900">
{getTodoDisplayTitle(task)}
</p>
</div>
</li>
))}
</ul>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
<button
type="button"
onClick={() => setHistoryExpanded((current) => !current)}
className="flex w-full items-center justify-between rounded-[20px] border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{historyTodos.length} </span>
</div>
<span className="text-sm font-medium text-slate-500">
{historyExpanded ? "收起" : "展开"}
</span>
</button>
{historyExpanded ? (
historyTodos.length ? (
<>
<ul className="mt-3 space-y-2.5 sm:space-y-3">
{visibleHistoryTodos.map((task: DashboardTodo) => (
<li key={task.id} className="flex items-start gap-3 rounded-[20px] border border-slate-100 bg-white px-4 py-3">
<div className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
<Check className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-slate-500 line-through">
{getTodoDisplayTitle(task)}
</p>
</div>
</li>
))}
</ul>
{hasMoreHistoryTodos ? (
<button
type="button"
onClick={() => setShowAllHistoryTodos((current) => !current)}
className="mt-3 w-full rounded-[18px] border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
{showAllHistoryTodos ? "收起历史明细" : `展开更多 (${historyTodos.length})`}
</button>
) : null}
</>
) : (
<div className="crm-empty-panel mt-3">
</div>
)
) : null}
</div>
</div>
) : (
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
{messages.length ? (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center gap-2 sm:mb-3">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{unreadMessages.length} </span>
</div>
{unreadMessages.length ? (
<ul className="space-y-2.5 sm:space-y-3">
{unreadMessages.map((message: DashboardMessage) => (
<li key={message.id}>
<button
type="button"
onClick={() => void handleMessageClick(message)}
disabled={readingMessageId === message.id}
className="w-full rounded-[20px] border border-slate-100 bg-white px-4 py-3 text-left transition-all hover:border-violet-200 hover:bg-violet-50/50 disabled:cursor-wait disabled:opacity-70"
>
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 text-sm font-semibold text-slate-900">
{message.senderName || "同事"}
</p>
<span className="shrink-0 text-[10px] text-slate-400">{message.timeText || message.reportDate || ""}</span>
</div>
{message.bizName ? (
<p className="mt-1 truncate text-xs font-medium text-violet-600">
{message.bizName}
</p>
) : null}
<p className="crm-field-note mt-1 line-clamp-2 whitespace-pre-line">{message.content || "无"}</p>
</button>
</li>
))}
</ul>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
<div>
<button
type="button"
onClick={() => setMessageHistoryExpanded((current) => !current)}
className="flex w-full items-center justify-between rounded-[20px] border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{readMessages.length} </span>
</div>
<span className="text-sm font-medium text-slate-500">
{messageHistoryExpanded ? "收起" : "展开"}
</span>
</button>
{messageHistoryExpanded ? (
readMessages.length ? (
<ul className="mt-3 space-y-2.5 sm:space-y-3">
{readMessages.map((message: DashboardMessage) => (
<li key={message.id}>
<button
type="button"
onClick={() => void handleMessageClick(message)}
disabled={readingMessageId === message.id}
className="w-full rounded-[20px] border border-slate-100 bg-white px-4 py-3 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 disabled:cursor-wait disabled:opacity-70"
>
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 truncate text-sm text-slate-500">
{message.senderName || "同事"}
</p>
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
</div>
<p className="crm-field-note mt-1 line-clamp-2 whitespace-pre-line">{message.content || "无"}</p>
</button>
</li>
))}
</ul>
) : (
<div className="crm-empty-panel mt-3">
</div>
)
) : null}
</div>
</div>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
)}
</motion.div>
) : null}
{showAnalyticsCard ? (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.45 }}
>
{analyticsCards.length ? (
<div className="space-y-3 min-[420px]:space-y-4">
<div className="space-y-3">
{home.analyticsPanel?.title || home.analyticsPanel?.subtitle ? (
<div className="crm-dashboard-panel-header">
<div className="crm-dashboard-panel-heading">
{home.analyticsPanel?.title ? (
<h2 className="crm-dashboard-panel-title">{home.analyticsPanel.title}</h2>
) : null}
{home.analyticsPanel?.subtitle ? (
<p className="crm-dashboard-panel-subtitle">{home.analyticsPanel.subtitle}</p>
) : null}
</div>
</div>
) : null}
{analyticsTabs.length ? (
<div className="crm-filter-bar overflow-x-auto px-1 py-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex min-w-full gap-1">
{analyticsTabs.map((tab) => {
const active = tab.key === activeAnalyticsTab;
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveAnalyticsTab(tab.key)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-200 max-sm:min-w-[84px] max-sm:flex-none sm:flex-1 ${
active
? "bg-white text-violet-600 shadow-sm"
: "text-slate-600 hover:text-slate-900"
}`}
aria-pressed={active}
>
{tab.title}
</button>
);
})}
</div>
</div>
) : null}
</div>
{visibleAnalyticsSections.map((section) => {
const showSectionShell = !section.isDefault || analyticsSections.length > 1;
const showSectionTitle = showSectionShell && activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY;
return (
<section
key={section.key}
className={showSectionShell
? "rounded-[28px] border border-slate-100 bg-white/96 p-4 shadow-[0_16px_36px_-28px_rgba(15,23,42,0.16)] min-[420px]:p-5"
: ""}
>
{showSectionTitle ? (
<div className="mb-3 min-[420px]:mb-4">
<h3 className="truncate text-sm font-bold text-slate-900 min-[420px]:text-[15px]">
{section.title || "未分组"}
</h3>
</div>
) : null}
<div className={`grid grid-cols-12 gap-3 min-[420px]:gap-4 ${isMobileViewport ? "" : "sm:gap-4"}`}>
{section.cards.map((card, index) => {
const clickable = supportsAnalyticsDetail(card);
const chartCard = isChartAnalyticsCard(card);
const horizontalColumns = resolveHorizontalColumns(card);
const mobileMetricScale = resolveMobileMetricScale(card, isMobileViewport);
const mobileMetricCompactLevel = resolveMobileMetricCompactLevel(mobileMetricScale);
const compactMobileCard = isMobileViewport && card.layoutType === "horizontal" && !card.fullRow && !chartCard && mobileMetricCompactLevel >= 1;
const ultraCompactMobileCard = compactMobileCard && mobileMetricCompactLevel >= 2;
const metricDisplayConfig = parseAnalyticsDisplayConfig(card.displayTextConfig);
const metricVisual = getAnalyticsMetricVisual(card, index);
const metricStyles = getMetricCardStyles(metricDisplayConfig, metricVisual.key);
const MetricIcon = metricVisual.icon;
const cardSummary = getAnalyticsCardPreviewSummary(card);
const dimensionOptions = getAnalyticsDimensionOptions(card);
const selectedDimension = card.cardKey
? analyticsSelectedDimensions[card.cardKey] || getAnalyticsDefaultDimension(card)
: "";
const dimensionLoading = card.cardKey ? analyticsDimensionLoadingKey === card.cardKey : false;
const hideMetricDecorationLevel1 = isMobileViewport && mobileMetricScale <= 0.82;
const hideMetricDecorationLevel2 = isMobileViewport && mobileMetricScale <= 0.68;
const showMetricIcon = !hideMetricDecorationLevel2;
const showMetricBadge = Boolean(metricDisplayConfig.metricBadgeText?.trim()) && !hideMetricDecorationLevel1;
const showMetricSubtitle = Boolean(card.subtitle) && !hideMetricDecorationLevel1;
const metricScaleStyle = mobileMetricScale < 1
? {
zoom: mobileMetricScale,
width: `${100 / mobileMetricScale}%`,
}
: undefined;
return (
<div
key={card.cardKey || card.id || card.title}
onClick={() => handleAnalyticsCardClick(card)}
onKeyDown={(event) => {
if (!clickable) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleAnalyticsCardClick(card);
}
}}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
className={`${getAnalyticsCardLayoutClass(card, isMobileViewport)} ${chartCard ? "overflow-visible" : "overflow-hidden"} text-left transition-all ${
clickable ? "cursor-pointer hover:-translate-y-0.5" : ""
} ${
chartCard
? `${compactMobileCard ? "rounded-[24px] p-4" : "rounded-[32px] p-6"} border border-slate-100 bg-white shadow-sm ${clickable ? "hover:shadow-md" : ""}`
: ultraCompactMobileCard
? "rounded-[18px] p-3"
: compactMobileCard
? "rounded-[20px] p-4"
: "rounded-[24px] p-5"
}`}
style={chartCard ? undefined : metricStyles.container}
>
{chartCard ? (
<>
<div className={`${compactMobileCard ? "mb-4 gap-2" : "mb-6 gap-4"} flex items-start justify-between`}>
<div className="min-w-0">
<h3 className={`${compactMobileCard ? "text-sm" : "text-base"} truncate font-bold text-slate-800`}>
{card.title || "未命名卡片"}
</h3>
{card.subtitle ? (
<p className={`${compactMobileCard ? "text-[9px]" : "text-[10px]"} mt-1 font-medium text-slate-400`}>
{card.subtitle}
</p>
) : null}
</div>
{dimensionOptions.length ? (
<div
ref={openDimensionCardKey === card.cardKey ? dimensionMenuRef : undefined}
className="relative shrink-0"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<button
type="button"
disabled={dimensionLoading}
aria-haspopup="listbox"
aria-expanded={openDimensionCardKey === card.cardKey}
aria-label={`${card.title || "图表"}维度筛选`}
onClick={() => setOpenDimensionCardKey((current) => current === card.cardKey ? null : (card.cardKey || null))}
className={`inline-flex h-[12px] items-center gap-0 rounded-sm bg-transparent px-0 text-[4px] font-medium text-slate-400 shadow-none transition-all ${
dimensionLoading
? "opacity-70"
: "hover:text-slate-600"
}`}
>
<span className="max-w-[18px] truncate leading-none">
{dimensionOptions.find((option) => option.value === selectedDimension)?.label || selectedDimension}
</span>
<ChevronDown
className={`h-1.5 w-1.5 text-slate-300 transition-transform ${
openDimensionCardKey === card.cardKey ? "rotate-180" : ""
}`}
/>
</button>
{openDimensionCardKey === card.cardKey ? (
<div
role="listbox"
className="absolute right-0 top-[calc(100%+4px)] z-20 min-w-[60px] overflow-hidden rounded-md bg-white/92 p-0.5 shadow-[0_14px_24px_-18px_rgba(15,23,42,0.22)] backdrop-blur"
>
{dimensionOptions.map((option) => {
const active = option.value === selectedDimension;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={active}
onClick={() => void handleAnalyticsDimensionChange(card, option.value)}
className={`flex w-full items-center justify-between rounded-sm px-1 py-[2px] text-left text-[4px] font-medium leading-none transition-colors ${
active
? "bg-slate-900 text-white"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
}`}
>
<span>{option.label}</span>
{active ? <Check className="h-1.5 w-1.5" /> : null}
</button>
);
})}
</div>
) : null}
</div>
) : (
<span className={`${compactMobileCard ? "p-1.5" : "p-2"} rounded-xl text-slate-300`}>
<MoreVertical className="h-4 w-4" />
</span>
)}
</div>
<DashboardAnalyticsChart card={card} />
{cardSummary ? (
<div className={`${compactMobileCard ? "mt-4 pt-3 text-[10px]" : "mt-5 pt-4 text-[11px]"} border-t border-slate-100 text-center text-slate-400`}>
{cardSummary}
</div>
) : null}
</>
) : (
<div style={metricScaleStyle}>
<div className="flex items-start justify-between gap-3">
{showMetricIcon ? (
<div
className={`${
ultraCompactMobileCard
? "h-8 w-8"
: compactMobileCard
? "h-9 w-9"
: "h-10 w-10"
} flex items-center justify-center`}
style={metricStyles.iconShell}
>
<MetricIcon
className={ultraCompactMobileCard ? "h-4 w-4" : "h-[18px] w-[18px]"}
style={{ color: metricStyles.iconColor }}
/>
</div>
) : (
<span />
)}
{showMetricBadge ? (
<span
className={`${ultraCompactMobileCard ? "px-2 py-1 text-[8px]" : "px-2.5 py-1 text-[10px]"} inline-flex items-center rounded-full font-bold`}
style={metricStyles.badgeStyle}
>
{metricDisplayConfig.metricBadgeText?.trim()}
</span>
) : null}
</div>
<p className={`${
ultraCompactMobileCard ? "text-[9px] leading-4" : "text-[10px]"
} ${showMetricIcon ? "mt-3" : "mt-1.5"} line-clamp-2 font-bold uppercase tracking-wider`} style={{ color: metricStyles.titleColor }}>
{card.title || "未命名卡片"}
</p>
<h3 className={`${
ultraCompactMobileCard ? "mt-1 text-lg leading-tight" : compactMobileCard ? "mt-1 text-[19px]" : "mt-1 text-xl"
} font-bold`} style={{ color: metricStyles.valueColor }}>
{card.valueText || card.value || "0"}
</h3>
{showMetricSubtitle ? (
<p
className={`${ultraCompactMobileCard ? "mt-1 text-[9px]" : "mt-1.5 text-[10px]"} font-medium`}
style={{ color: metricStyles.metaColor }}
>
{card.subtitle}
</p>
) : null}
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
) : (
<div className="crm-empty-panel">
{home.analyticsPanel?.emptyStateText || "暂无可展示的经营分析卡片"}
</div>
)}
</motion.div>
) : null}
{showActivityCard ? (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.5 }}
className="overflow-hidden rounded-[30px] border border-slate-100 bg-white p-5 shadow-[0_16px_38px_-30px_rgba(15,23,42,0.14)]"
>
<div className="crm-dashboard-panel-header">
<div className="crm-dashboard-panel-heading">
<h2 className="crm-dashboard-panel-title"></h2>
<p className="crm-dashboard-panel-subtitle"></p>
</div>
<span className="crm-dashboard-panel-meta">{visibleActivities.length} </span>
</div>
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
<div className="crm-section-stack sm:gap-5">
{visibleActivities.map((news: DashboardActivity, i: number) => (
<button
key={news.id ?? i}
type="button"
onClick={() => handleActivityClick(news)}
disabled={!news?.bizId || !news?.bizType}
className="group flex w-full gap-4 rounded-[20px] border border-slate-100 bg-white px-4 py-3 text-left transition-colors hover:border-slate-200 hover:bg-slate-50/70 disabled:cursor-default disabled:hover:border-slate-100 disabled:hover:bg-white"
>
<div className="relative mt-1 flex h-3 w-3 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-20"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500"></span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-semibold text-slate-900 transition-colors group-hover:text-slate-700">
{news.title || "无"}
</p>
{news.bizId ? (
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
</span>
) : null}
</div>
<p className="crm-field-note mt-0.5 line-clamp-2">{news.content || "无"}</p>
<div className="mt-1 flex items-center gap-2 text-[10px] text-slate-400">
<span>{news.timeText || "无"}</span>
{news.operatorName ? <span>· {news.operatorName}</span> : null}
</div>
</div>
</button>
))}
</div>
{hasMoreActivities && !showAllActivities ? (
<button
type="button"
onClick={() => setShowAllActivities(true)}
className="mt-4 w-full rounded-[18px] border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
...
</button>
) : null}
</div>
</motion.div>
) : null}
</div>
</motion.div>
)}
{messageDetailMessage ? (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/45 p-3 sm:items-center sm:p-6"
onClick={closeMessageDetail}
>
<div
className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-[28px] bg-white shadow-[0_28px_80px_-28px_rgba(15,23,42,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex min-h-[88px] items-start justify-between gap-4 border-b border-slate-200 px-5 py-5 sm:px-6">
<div className="min-w-0 space-y-1.5">
<h3 className="text-[17px] font-semibold text-slate-900 sm:text-[19px]"></h3>
<p className="text-xs leading-5 text-slate-500 sm:text-sm">
{messageDetailMessage.senderName || "同事"} · {messageDetailDate || "无日期"}{messageDetailTime ? ` ${messageDetailTime}` : ""}
</p>
</div>
<button
type="button"
onClick={closeMessageDetail}
className="rounded-full border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
aria-label="关闭日报详情"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-y-auto px-5 py-5 sm:px-6 sm:py-6">
{messageDetailLoading ? (
<div className="crm-empty-panel">...</div>
) : messageDetailError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{messageDetailError}
</div>
) : (
<div className="space-y-4">
{messageDetailMessage.bizName ? (
<div className="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3">
<p className="text-xs font-medium text-violet-500"></p>
<p className="mt-1 text-sm font-semibold text-violet-700">{messageDetailMessage.bizName}</p>
</div>
) : null}
{hasMessageDetailLineItems ? (
<div className="space-y-3">
<p className="text-xs font-medium text-slate-500"></p>
{messageDetailLineItems.map((lineItem, lineIndex) => {
const lineAttachments = lineItem.attachments ?? [];
return (
<div key={`${messageDetailMessage.id}-message-line-${lineIndex}`} className="rounded-2xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-violet-50 px-2.5 py-1 text-xs font-semibold text-violet-600">
{lineIndex + 1}
</span>
{lineItem.bizName ? (
<span className="text-xs font-medium text-slate-500">
{getDashboardBizTypeLabel(lineItem.bizType)} · {lineItem.bizName}
</span>
) : null}
</div>
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-slate-700">
{getDashboardReportLineDisplayText(lineItem) || "无"}
</p>
{lineAttachments.length ? (
<div className="mt-3 border-t border-slate-200/80 pt-3">
<p className="mb-2 text-xs font-medium text-slate-500"> · {lineAttachments.length} </p>
<DashboardAttachmentList attachments={lineAttachments} onPreview={setPreviewAttachment} />
</div>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<p className="text-xs font-medium text-slate-500"></p>
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-700">
{messageDetailContent || "无"}
</p>
</div>
)}
{messageDetailAttachments.length ? (
<div className="rounded-2xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<p className="mb-2 text-xs font-medium text-slate-500"> · {messageDetailAttachments.length} </p>
<DashboardAttachmentList attachments={messageDetailAttachments} onPreview={setPreviewAttachment} />
</div>
) : null}
{messageDetailItem?.comment ? (
<div className="rounded-2xl border border-slate-100 bg-white px-4 py-3">
<p className="text-xs font-medium text-slate-500"></p>
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-700">{messageDetailItem.comment}</p>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
) : null}
{previewAttachment ? (
<AttachmentPreviewModal attachment={previewAttachment} onClose={() => setPreviewAttachment(null)} />
) : null}
{detailCard ? (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/45 p-3 sm:items-center sm:p-6"
onClick={closeAnalyticsDetail}
>
<div
className="flex max-h-[88vh] w-full max-w-3xl flex-col overflow-hidden rounded-[28px] bg-white shadow-[0_28px_80px_-28px_rgba(15,23,42,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex min-h-[88px] items-start justify-between gap-4 border-b border-slate-200 px-5 py-5 sm:px-6">
<div className="min-w-0 space-y-1.5">
<h3 className="text-[17px] font-semibold text-slate-900 sm:text-[19px]">{detailCard.title || "经营分析详情"}</h3>
{detailCard.subtitle ? (
<p className="text-xs leading-5 text-slate-500 sm:text-sm">{detailCard.subtitle}</p>
) : null}
</div>
<button
type="button"
onClick={closeAnalyticsDetail}
className="rounded-full border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
aria-label="关闭经营分析详情"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-y-auto px-5 py-5 sm:px-6 sm:py-6">
{detailLoading ? (
<div className="rounded-3xl bg-slate-50/80 p-4 sm:p-5">
<div className="crm-empty-panel">...</div>
</div>
) : detailError ? (
<div className="rounded-3xl bg-slate-50/80 p-4 sm:p-5">
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{detailError}
</div>
</div>
) : (
<div className="rounded-3xl bg-slate-50/80 p-4 sm:p-5">
<DashboardAnalyticsChart card={detailCard} expanded />
</div>
)}
</div>
</div>
</div>
) : null}
</div>
);
}
function DashboardSkeleton() {
return (
<div className="animate-[fadeIn_220ms_ease-out]">
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
{[0, 1, 2, 3].map((item) => (
<div
key={`dashboard-stat-skeleton-${item}`}
className="crm-card min-h-[88px] rounded-xl p-3 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
>
<div className="flex items-center gap-2.5 sm:gap-4">
<div className="h-9 w-9 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800 sm:h-12 sm:w-12 sm:rounded-xl" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800 sm:h-4" />
<div className="h-6 w-10 animate-pulse rounded bg-slate-100 dark:bg-slate-800 sm:h-8" />
</div>
</div>
</div>
))}
</div>
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
<div className="crm-card crm-card-pad-lg rounded-2xl">
<div className="mb-4 flex items-center justify-between">
<div className="h-5 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-12 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
<div className="crm-section-stack sm:gap-5">
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
<div className="mb-3 flex items-center gap-2">
<div className="h-6 w-10 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
<div className="space-y-3">
{[0, 1].map((item) => (
<div key={`todo-skeleton-${item}`} className="flex items-start gap-3 rounded-xl border border-white/80 bg-white px-3 py-3 dark:border-slate-700/60 dark:bg-slate-900/40">
<div className="mt-0.5 h-5 w-5 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
<div className="flex-1 space-y-2">
<div className="h-3 w-11/12 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-2/3 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
</div>
))}
</div>
</div>
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-900/40">
<div className="flex items-center gap-2">
<div className="h-6 w-10 animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-20 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
<div className="h-4 w-10 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
</div>
</div>
</div>
<div className="crm-card crm-card-pad-lg rounded-2xl">
<div className="mb-4 h-5 w-24 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="crm-section-stack sm:gap-5">
{[0, 1, 2].map((item) => (
<div key={`activity-skeleton-${item}`} className="flex gap-4">
<div className="mt-1 h-3 w-3 animate-pulse rounded-full bg-violet-100 dark:bg-violet-500/20" />
<div className="flex-1 space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-full animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
<div className="h-3 w-10 animate-pulse rounded bg-slate-100 dark:bg-slate-800" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
function getTodoDisplayTitle(task: DashboardTodo) {
const title = task.title?.trim();
if (!title) {
return "无";
}
if (task.bizType === "report" && title.startsWith("明日工作计划:")) {
return title.slice("明日工作计划:".length).trim() || "明日工作计划";
}
return title;
}
function getDashboardBizTypeLabel(bizType?: WorkReportLineItem["bizType"]) {
if (bizType === "sales") {
return "人员拓展";
}
if (bizType === "channel") {
return "渠道拓展";
}
if (bizType === "opportunity") {
return "商机";
}
return "记录";
}
function getDashboardReportLineDisplayText(lineItem: WorkReportLineItem) {
const content = lineItem.content?.trim() || lineItem.editorText?.trim() || "";
if (!content) {
return "";
}
return content
.replace(/\r/g, "")
.split("\n")
.filter((line) => {
const normalizedLine = line.trim();
return normalizedLine && !normalizedLine.startsWith("#") && !/^to\s*[:]/i.test(normalizedLine);
})
.join("\n")
.replace(/[;]?\s*\s*\d+\s*\s*$/u, "")
.trim();
}
function getDashboardMessageLineIndexSet(message: DashboardMessage | null) {
const indexes = new Set<number>();
const rawIndexes = message?.lineIndexes?.split(",") ?? [];
for (const rawIndex of rawIndexes) {
const index = Number(rawIndex);
if (Number.isInteger(index) && index >= 0) {
indexes.add(index);
}
}
return indexes;
}
function DashboardAttachmentList({
attachments,
onPreview,
}: {
attachments: WorkReportAttachment[];
onPreview: (attachment: WorkReportAttachment) => void;
}) {
if (!attachments.length) {
return null;
}
return (
<div className="flex flex-wrap gap-2">
{attachments.map((attachment) => (
<button
key={attachment.url}
type="button"
onClick={() => onPreview(attachment)}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-700"
title="预览附件"
>
<Paperclip className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[180px] truncate">{attachment.name || "附件"}</span>
<span className="shrink-0 text-slate-400">{formatAttachmentSize(attachment.size)}</span>
</button>
))}
</div>
);
}