unis_crm/frontend/src/pages/Dashboard.tsx

1899 lines
86 KiB
TypeScript
Raw Normal View History

2026-05-20 02:01:28 +00:00
import { useEffect, useMemo, useRef, useState } from "react";
2026-04-30 07:06:53 +00:00
import {
Activity,
ArrowDownRight,
ArrowUpRight,
BadgeDollarSign,
BarChart3,
BriefcaseBusiness,
Building2,
CalendarDays,
2026-05-20 02:01:28 +00:00
ChevronDown,
2026-04-30 07:06:53 +00:00
Check,
CircleDollarSign,
ClipboardList,
DollarSign,
FileText,
HandCoins,
Landmark,
Megaphone,
MoreVertical,
Package,
Paperclip,
2026-04-30 07:06:53 +00:00
PhoneCall,
PieChart,
Rocket,
ShoppingCart,
Store,
Target,
TrendingUp,
Trophy,
Users,
Wallet,
X,
Zap,
} from "lucide-react";
2026-03-19 06:23:03 +00:00
import { motion } from "motion/react";
2026-03-27 04:22:00 +00:00
import { useNavigate } from "react-router-dom";
2026-04-30 07:06:53 +00:00
import {
completeDashboardTodo,
getDashboardAnalyticsCardDetail,
getDashboardHome,
getWorkReportHistoryItem,
readDashboardMessage,
2026-04-30 07:06:53 +00:00
type DashboardActivity,
type DashboardAnalyticsCard,
type DashboardHome,
type DashboardMessage,
2026-04-30 07:06:53 +00:00
type DashboardStat,
type DashboardTodo,
type WorkHistoryItem,
type WorkReportAttachment,
type WorkReportLineItem,
2026-04-30 07:06:53 +00:00
} from "@/lib/auth";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
2026-04-30 07:06:53 +00:00
import DashboardAnalyticsChart from "@/components/dashboard/DashboardAnalyticsChart";
import { AttachmentPreviewModal, formatAttachmentSize } from "@/components/AttachmentPreviewModal";
2026-03-20 08:39:07 +00:00
const DASHBOARD_PREVIEW_COUNT = 5;
2026-03-26 09:29:55 +00:00
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
2026-03-20 08:39:07 +00:00
const baseStats = [
2026-04-15 10:15:06 +00:00
{ 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" },
2026-03-20 08:39:07 +00:00
] as const;
2026-03-19 06:23:03 +00:00
2026-03-27 04:22:00 +00:00
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" } },
2026-04-15 10:11:16 +00:00
monthlyWonOpportunities: { pathname: "/opportunities" },
2026-03-27 04:22:00 +00:00
};
2026-04-15 10:11:16 +00:00
const amountMetricKeys = new Set<(typeof baseStats)[number]["metricKey"]>([
"monthlyOpportunities",
"pushedOmsProjects",
"monthlyWonOpportunities",
]);
2026-04-30 07:06:53 +00:00
type AnalyticsCardDisplayConfig = Partial<{
horizontalColumns: number;
metricIconKey: string;
2026-05-20 02:01:28 +00:00
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;
};
2026-04-30 07:06:53 +00:00
}>;
2026-05-20 02:01:28 +00:00
type AnalyticsDimensionOption = {
value: string;
label: string;
};
2026-04-30 07:06:53 +00:00
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;
};
2026-05-20 02:01:28 +00:00
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";
2026-04-15 10:11:16 +00:00
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: "万元",
};
}
2026-04-15 10:15:06 +00:00
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]";
2026-04-15 10:11:16 +00:00
}
2026-04-15 10:15:06 +00:00
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]";
2026-04-15 10:11:16 +00:00
}
2026-04-30 07:06:53 +00:00
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);
2026-05-20 02:01:28 +00:00
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,
};
2026-04-30 07:06:53 +00:00
} catch {
return {};
}
}
2026-05-20 02:01:28 +00:00
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 || "";
}
2026-04-30 07:06:53 +00:00
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",
};
2026-05-20 02:01:28 +00:00
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})`;
}
2026-04-30 07:06:53 +00:00
function resolveConfiguredMetricIconKey(metricIconKey?: string) {
if (!metricIconKey) {
return "";
}
if (METRIC_ICON_VISUALS[metricIconKey]) {
return metricIconKey;
}
return LEGACY_METRIC_ICON_KEY_MAP[metricIconKey] || "";
}
2026-05-20 02:01:28 +00:00
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)) {
2026-04-30 07:06:53 +00:00
return "col-span-12";
}
if (card.layoutType !== "horizontal") {
return "col-span-12";
}
2026-05-20 02:01:28 +00:00
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";
}
}
2026-04-30 07:06:53 +00:00
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];
}
2026-05-20 02:01:28 +00:00
function getAnalyticsMetricFootnote(card: DashboardAnalyticsCard, config: AnalyticsCardDisplayConfig) {
2026-04-30 07:06:53 +00:00
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);
2026-05-20 02:01:28 +00:00
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;
}
2026-04-30 07:06:53 +00:00
return {
2026-05-20 02:01:28 +00:00
text: text || fallbackText,
tone,
2026-04-30 07:06:53 +00:00
} as const;
}
2026-05-20 02:01:28 +00:00
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;
}
2026-03-19 06:23:03 +00:00
export default function Dashboard() {
2026-03-27 04:22:00 +00:00
const navigate = useNavigate();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
2026-03-20 08:39:07 +00:00
const [home, setHome] = useState<DashboardHome>({});
2026-03-26 09:29:55 +00:00
const [loading, setLoading] = useState(true);
2026-03-20 08:39:07 +00:00
const [showAllActivities, setShowAllActivities] = useState(false);
2026-03-26 09:29:55 +00:00
const [showAllHistoryTodos, setShowAllHistoryTodos] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [messageHistoryExpanded, setMessageHistoryExpanded] = useState(false);
const [activeWorkTab, setActiveWorkTab] = useState<"todo" | "message">("todo");
2026-03-26 09:29:55 +00:00
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);
2026-04-30 07:06:53 +00:00
const [detailCard, setDetailCard] = useState<DashboardAnalyticsCard | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string>("");
2026-05-20 02:01:28 +00:00
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;
});
2026-03-20 08:39:07 +00:00
useEffect(() => {
let cancelled = false;
async function loadDashboard() {
try {
const data = await getDashboardHome();
if (!cancelled) {
setHome(data ?? {});
}
} catch {
if (!cancelled) {
setHome({});
}
2026-03-26 09:29:55 +00:00
} finally {
if (!cancelled) {
setLoading(false);
}
2026-03-20 08:39:07 +00:00
}
}
void loadDashboard();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
setShowAllActivities(false);
2026-03-26 09:29:55 +00:00
setShowAllHistoryTodos(false);
setHistoryExpanded(false);
setMessageHistoryExpanded(false);
}, [home.activities, home.todos, home.messages]);
2026-03-20 08:39:07 +00:00
2026-04-15 10:11:16 +00:00
const statMap = new Map<string, number | undefined>((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
2026-03-20 08:39:07 +00:00
const stats = baseStats.map((stat) => ({
...stat,
value: statMap.get(stat.metricKey),
}));
2026-03-26 09:29:55 +00:00
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],
);
2026-03-26 09:29:55 +00:00
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
2026-03-20 08:39:07 +00:00
const activities = (home.activities?.length
? home.activities
: [{ id: "0", title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
2026-03-20 08:39:07 +00:00
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== "0";
2026-03-26 09:29:55 +00:00
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
2026-04-30 07:06:53 +00:00
const showStatsCard = home.statsCardVisible !== false;
2026-04-16 02:55:37 +00:00
const showTodoCard = home.todoCardVisible !== false;
const showActivityCard = home.activityCardVisible !== false;
2026-04-30 07:06:53 +00:00
const showAnalyticsCard = home.analyticsCardVisible !== false && home.analyticsPanel?.enabled === true;
const showWorkCard = showTodoCard || messages.length > 0;
const activeWorkPanel = showTodoCard ? activeWorkTab : "message";
2026-05-20 02:01:28 +00:00
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]);
2026-03-26 09:29:55 +00:00
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);
}
};
2026-03-19 06:23:03 +00:00
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 ?? [];
2026-03-27 04:22:00 +00:00
const handleStatCardClick = (metricKey: (typeof baseStats)[number]["metricKey"]) => {
const target = statRoutes[metricKey];
if (!target) {
return;
}
navigate(target.pathname, target.state ? { state: target.state } : undefined);
};
2026-04-16 02:55:37 +00:00
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 } });
}
};
2026-04-30 07:06:53 +00:00
const handleAnalyticsCardClick = (card: DashboardAnalyticsCard) => {
if (!supportsAnalyticsDetail(card)) {
return;
}
void openAnalyticsCardDetail(card);
};
2026-05-20 02:01:28 +00:00
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("");
}
};
2026-04-30 07:06:53 +00:00
const openAnalyticsCardDetail = async (card: DashboardAnalyticsCard) => {
if (!card?.cardKey || !supportsAnalyticsDetail(card)) {
return;
}
2026-05-20 02:01:28 +00:00
const selectedDimension = analyticsSelectedDimensions[card.cardKey] || getAnalyticsDefaultDimension(card);
2026-04-30 07:06:53 +00:00
setDetailLoading(true);
setDetailError("");
setDetailCard({
...card,
chartData: [],
});
try {
2026-05-20 02:01:28 +00:00
const data = await getDashboardAnalyticsCardDetail(card.cardKey, selectedDimension || undefined);
2026-04-30 07:06:53 +00:00
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";
2026-03-19 06:23:03 +00:00
return (
2026-03-26 09:29:55 +00:00
<div className="crm-page-stack">
2026-03-27 09:05:41 +00:00
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle">
2026-04-02 02:07:21 +00:00
{home.realName || "无"}
2026-03-27 09:05:41 +00:00
</p>
</div>
2026-03-19 06:23:03 +00:00
</header>
2026-03-26 09:29:55 +00:00
{loading ? (
2026-03-27 01:16:04 +00:00
<motion.div
key="dashboard-skeleton"
initial={disableMobileMotion ? false : { opacity: 0 }}
2026-03-27 01:16:04 +00:00
animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.22, ease: "easeOut" }}
2026-03-27 01:16:04 +00:00
>
<DashboardSkeleton />
</motion.div>
2026-03-26 09:29:55 +00:00
) : (
2026-03-27 01:16:04 +00:00
<motion.div
key="dashboard-content"
initial={disableMobileMotion ? false : { opacity: 0 }}
2026-03-27 01:16:04 +00:00
animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
2026-03-27 01:16:04 +00:00
>
2026-04-30 07:06:53 +00:00
{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>
2026-04-15 10:11:16 +00:00
</div>
2026-04-30 07:06:53 +00:00
<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>
2026-04-15 10:11:16 +00:00
</div>
</div>
2026-04-30 07:06:53 +00:00
</motion.button>
);
})}
</div>
) : null}
2026-03-26 09:29:55 +00:00
2026-04-30 07:06:53 +00:00
<div className={`${showStatsCard ? "mt-5 md:mt-6 " : ""}grid gap-5 ${dashboardPanelGridClassName} md:gap-6`}>
{showWorkCard ? (
2026-03-26 09:29:55 +00:00
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
2026-03-26 09:29:55 +00:00
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.4 }}
2026-04-30 07:06:53 +00:00
className="overflow-hidden rounded-[30px] border border-slate-100 bg-white p-5 shadow-[0_16px_38px_-30px_rgba(15,23,42,0.14)]"
2026-03-26 09:29:55 +00:00
>
2026-04-30 07:06:53 +00:00
<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>
2026-04-30 07:06:53 +00:00
</div>
<span className="crm-dashboard-panel-meta">
{pendingTodos.length + historyTodos.length} · {messages.length}
2026-03-26 09:29:55 +00:00
</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" ? (
2026-03-26 09:29:55 +00:00
<div className="crm-section-stack sm:gap-5">
2026-04-30 07:06:53 +00:00
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
2026-03-26 09:29:55 +00:00
<div className="mb-2 flex items-center gap-2 sm:mb-3">
2026-04-30 07:06:53 +00:00
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
2026-03-26 09:29:55 +00:00
<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) => (
2026-04-30 07:06:53 +00:00
<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">
2026-03-26 09:29:55 +00:00
<button
type="button"
onClick={() => void handleCompleteTodo(task.id)}
disabled={completingTodoId === task.id}
2026-04-30 07:06:53 +00:00
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"
2026-03-26 09:29:55 +00:00
aria-label={`完成待办:${getTodoDisplayTitle(task)}`}
>
<Check className="h-3 w-3" />
</button>
<div className="min-w-0 flex-1">
2026-04-30 07:06:53 +00:00
<p className="truncate text-sm font-medium text-slate-700 transition-colors group-hover:text-slate-900">
2026-03-26 09:29:55 +00:00
{getTodoDisplayTitle(task)}
</p>
</div>
</li>
))}
</ul>
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</div>
)}
</div>
2026-04-30 07:06:53 +00:00
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
2026-03-26 09:29:55 +00:00
<button
type="button"
onClick={() => setHistoryExpanded((current) => !current)}
2026-04-30 07:06:53 +00:00
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"
2026-03-26 09:29:55 +00:00
>
<div className="flex items-center gap-2">
2026-04-30 07:06:53 +00:00
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
2026-03-26 09:29:55 +00:00
<span className="text-xs text-slate-400 dark:text-slate-500">{historyTodos.length} </span>
</div>
2026-04-30 07:06:53 +00:00
<span className="text-sm font-medium text-slate-500">
2026-03-26 09:29:55 +00:00
{historyExpanded ? "收起" : "展开"}
</span>
</button>
{historyExpanded ? (
historyTodos.length ? (
<>
<ul className="mt-3 space-y-2.5 sm:space-y-3">
{visibleHistoryTodos.map((task: DashboardTodo) => (
2026-04-30 07:06:53 +00:00
<li key={task.id} className="flex items-start gap-3 rounded-[20px] border border-slate-100 bg-white px-4 py-3">
2026-03-26 09:29:55 +00:00
<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">
2026-04-30 07:06:53 +00:00
<p className="truncate text-sm text-slate-500 line-through">
2026-03-26 09:29:55 +00:00
{getTodoDisplayTitle(task)}
</p>
</div>
</li>
))}
</ul>
{hasMoreHistoryTodos ? (
<button
type="button"
onClick={() => setShowAllHistoryTodos((current) => !current)}
2026-04-30 07:06:53 +00:00
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"
2026-03-26 09:29:55 +00:00
>
{showAllHistoryTodos ? "收起历史明细" : `展开更多 (${historyTodos.length})`}
</button>
) : null}
</>
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel mt-3">
2026-03-26 09:29:55 +00:00
</div>
)
) : null}
</div>
2026-03-19 06:23:03 +00:00
</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>
)}
2026-03-26 09:29:55 +00:00
</motion.div>
2026-04-16 02:55:37 +00:00
) : null}
2026-03-26 09:29:55 +00:00
2026-04-30 07:06:53 +00:00
{showAnalyticsCard ? (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.45 }}
>
{analyticsCards.length ? (
2026-05-20 02:01:28 +00:00
<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) => {
2026-04-30 07:06:53 +00:00
const clickable = supportsAnalyticsDetail(card);
const chartCard = isChartAnalyticsCard(card);
const horizontalColumns = resolveHorizontalColumns(card);
2026-05-20 02:01:28 +00:00
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);
2026-04-30 07:06:53 +00:00
const metricVisual = getAnalyticsMetricVisual(card, index);
2026-05-20 02:01:28 +00:00
const metricStyles = getMetricCardStyles(metricDisplayConfig, metricVisual.key);
2026-04-30 07:06:53 +00:00
const MetricIcon = metricVisual.icon;
const cardSummary = getAnalyticsCardPreviewSummary(card);
2026-05-20 02:01:28 +00:00
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;
2026-04-30 07:06:53 +00:00
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}
2026-05-20 02:01:28 +00:00
className={`${getAnalyticsCardLayoutClass(card, isMobileViewport)} ${chartCard ? "overflow-visible" : "overflow-hidden"} text-left transition-all ${
clickable ? "cursor-pointer hover:-translate-y-0.5" : ""
2026-04-30 07:06:53 +00:00
} ${
chartCard
2026-05-20 02:01:28 +00:00
? `${compactMobileCard ? "rounded-[24px] p-4" : "rounded-[32px] p-6"} border border-slate-100 bg-white shadow-sm ${clickable ? "hover:shadow-md" : ""}`
2026-04-30 07:06:53 +00:00
: ultraCompactMobileCard
? "rounded-[18px] p-3"
: compactMobileCard
? "rounded-[20px] p-4"
: "rounded-[24px] p-5"
}`}
2026-05-20 02:01:28 +00:00
style={chartCard ? undefined : metricStyles.container}
2026-04-30 07:06:53 +00:00
>
{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>
2026-05-20 02:01:28 +00:00
{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>
)}
2026-04-30 07:06:53 +00:00
</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}
</>
) : (
2026-05-20 02:01:28 +00:00
<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}
2026-04-30 07:06:53 +00:00
</div>
<p className={`${
ultraCompactMobileCard ? "text-[9px] leading-4" : "text-[10px]"
2026-05-20 02:01:28 +00:00
} ${showMetricIcon ? "mt-3" : "mt-1.5"} line-clamp-2 font-bold uppercase tracking-wider`} style={{ color: metricStyles.titleColor }}>
2026-04-30 07:06:53 +00:00
{card.title || "未命名卡片"}
</p>
<h3 className={`${
ultraCompactMobileCard ? "mt-1 text-lg leading-tight" : compactMobileCard ? "mt-1 text-[19px]" : "mt-1 text-xl"
2026-05-20 02:01:28 +00:00
} font-bold`} style={{ color: metricStyles.valueColor }}>
2026-04-30 07:06:53 +00:00
{card.valueText || card.value || "0"}
</h3>
2026-05-20 02:01:28 +00:00
{showMetricSubtitle ? (
<p
className={`${ultraCompactMobileCard ? "mt-1 text-[9px]" : "mt-1.5 text-[10px]"} font-medium`}
style={{ color: metricStyles.metaColor }}
2026-04-30 07:06:53 +00:00
>
{card.subtitle}
</p>
) : null}
2026-05-20 02:01:28 +00:00
</div>
2026-04-30 07:06:53 +00:00
)}
</div>
);
2026-05-20 02:01:28 +00:00
})}
</div>
</section>
);
2026-04-30 07:06:53 +00:00
})}
</div>
) : (
<div className="crm-empty-panel">
{home.analyticsPanel?.emptyStateText || "暂无可展示的经营分析卡片"}
</div>
)}
</motion.div>
) : null}
2026-04-16 02:55:37 +00:00
{showActivityCard ? (
2026-03-26 09:29:55 +00:00
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
2026-03-26 09:29:55 +00:00
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.5 }}
2026-04-30 07:06:53 +00:00
className="overflow-hidden rounded-[30px] border border-slate-100 bg-white p-5 shadow-[0_16px_38px_-30px_rgba(15,23,42,0.14)]"
2026-03-26 09:29:55 +00:00
>
2026-04-30 07:06:53 +00:00
<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">
2026-03-26 09:29:55 +00:00
{visibleActivities.map((news: DashboardActivity, i: number) => (
2026-04-16 02:55:37 +00:00
<button
key={news.id ?? i}
type="button"
onClick={() => handleActivityClick(news)}
disabled={!news?.bizId || !news?.bizType}
2026-04-30 07:06:53 +00:00
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"
2026-04-16 02:55:37 +00:00
>
<div className="relative mt-1 flex h-3 w-3 shrink-0 items-center justify-center">
2026-03-26 09:29:55 +00:00
<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>
2026-04-16 02:55:37 +00:00
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
2026-04-30 07:06:53 +00:00
<p className="text-sm font-semibold text-slate-900 transition-colors group-hover:text-slate-700">
2026-04-16 02:55:37 +00:00
{news.title || "无"}
</p>
{news.bizId ? (
2026-04-30 07:06:53 +00:00
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
2026-04-16 02:55:37 +00:00
</span>
) : null}
</div>
<p className="crm-field-note mt-0.5 line-clamp-2">{news.content || "无"}</p>
2026-04-30 07:06:53 +00:00
<div className="mt-1 flex items-center gap-2 text-[10px] text-slate-400">
2026-04-16 02:55:37 +00:00
<span>{news.timeText || "无"}</span>
{news.operatorName ? <span>· {news.operatorName}</span> : null}
</div>
2026-03-26 09:29:55 +00:00
</div>
2026-04-16 02:55:37 +00:00
</button>
2026-03-26 09:29:55 +00:00
))}
2026-04-30 07:06:53 +00:00
</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}
2026-03-26 09:29:55 +00:00
</div>
</motion.div>
2026-04-16 02:55:37 +00:00
) : null}
2026-03-26 09:29:55 +00:00
</div>
2026-03-27 01:16:04 +00:00
</motion.div>
2026-03-26 09:29:55 +00:00
)}
2026-04-30 07:06:53 +00:00
{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}
2026-04-30 07:06:53 +00:00
{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}
2026-03-26 09:29:55 +00:00
</div>
);
}
function DashboardSkeleton() {
return (
2026-03-27 01:16:04 +00:00
<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">
2026-03-26 09:29:55 +00:00
{[0, 1, 2, 3].map((item) => (
<div
key={`dashboard-stat-skeleton-${item}`}
2026-03-27 01:16:04 +00:00
className="crm-card min-h-[88px] rounded-xl p-3 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
2026-03-26 09:29:55 +00:00
>
<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" />
2026-03-19 06:23:03 +00:00
</div>
</div>
2026-03-26 09:29:55 +00:00
</div>
2026-03-19 06:23:03 +00:00
))}
</div>
2026-03-27 01:16:04 +00:00
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
2026-03-26 09:29:55 +00:00
<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" />
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<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" />
2026-03-19 06:23:03 +00:00
</div>
</div>
))}
</div>
2026-03-26 09:29:55 +00:00
</div>
2026-03-19 06:23:03 +00:00
</div>
2026-03-27 01:16:04 +00:00
</div>
2026-03-19 06:23:03 +00:00
);
}
2026-03-26 09:29:55 +00:00
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>
);
}