import { useEffect, useMemo, useState } from "react"; import { Activity, ArrowDownRight, ArrowUpRight, BadgeDollarSign, BarChart3, BriefcaseBusiness, Building2, CalendarDays, Check, CircleDollarSign, ClipboardList, DollarSign, FileText, HandCoins, Landmark, Megaphone, MoreVertical, Package, 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, type DashboardActivity, type DashboardAnalyticsCard, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import DashboardAnalyticsChart from "@/components/dashboard/DashboardAnalyticsChart"; 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; }>; 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; }; 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); return parsed && typeof parsed === "object" ? parsed as AnalyticsCardDisplayConfig : {}; } catch { return {}; } } 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 = Object.fromEntries( METRIC_ICON_LIBRARY.map((item) => [item.key, item] as const) ); const LEGACY_METRIC_ICON_KEY_MAP: Record = { revenue: "dollar-sign", customers: "users", orders: "shopping-cart", growth: "trending-up", analytics: "bar-chart-3", channel: "building-2", }; function resolveConfiguredMetricIconKey(metricIconKey?: string) { if (!metricIconKey) { return ""; } if (METRIC_ICON_VISUALS[metricIconKey]) { return metricIconKey; } return LEGACY_METRIC_ICON_KEY_MAP[metricIconKey] || ""; } function getAnalyticsCardLayoutClass(card: DashboardAnalyticsCard) { if (card.renderType === "table" || card.fullRow) { return "col-span-12"; } if (card.layoutType !== "horizontal") { return "col-span-12"; } 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) { const text = card.description?.trim() || card.subtitle?.trim() || ""; if (!text) { return null; } 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); return { text, tone: positive ? "up" : negative ? "down" : "neutral", } as const; } export default function Dashboard() { const navigate = useNavigate(); const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; const [home, setHome] = useState({}); const [loading, setLoading] = useState(true); const [showAllActivities, setShowAllActivities] = useState(false); const [showAllHistoryTodos, setShowAllHistoryTodos] = useState(false); const [historyExpanded, setHistoryExpanded] = useState(false); const [completingTodoId, setCompletingTodoId] = useState(null); const [detailCard, setDetailCard] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(""); 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); }, [home.activities, home.todos]); const statMap = new Map((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 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 analyticsCards = (home.analyticsPanel?.cards ?? []).filter((item) => !item.errorMessage); 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 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 openAnalyticsCardDetail = async (card: DashboardAnalyticsCard) => { if (!card?.cardKey || !supportsAnalyticsDetail(card)) { return; } setDetailLoading(true); setDetailError(""); setDetailCard({ ...card, chartData: [], }); try { const data = await getDashboardAnalyticsCardDetail(card.cardKey); 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 (

工作台

欢迎回来,{home.realName || "无"}。

{loading ? ( ) : ( {showStatsCard ? (
{stats.map((stat, i) => { const display = formatStatDisplay(stat.metricKey, stat.value); const valueClassName = getStatValueClass(display.value); return ( 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}`} >

{stat.name}

{display.value}

{display.unit}
); })}
) : null}
{showTodoCard ? (

待办事项

集中处理个人待办和历史完成记录

共 {pendingTodos.length + historyTodos.length} 条
待办 {pendingTodos.length} 条未完成
{pendingTodos.length ? (
    {pendingTodos.map((task: DashboardTodo) => (
  • {getTodoDisplayTitle(task)}

  • ))}
) : (
暂无未完成待办
)}
{historyExpanded ? ( historyTodos.length ? ( <>
    {visibleHistoryTodos.map((task: DashboardTodo) => (
  • {getTodoDisplayTitle(task)}

  • ))}
{hasMoreHistoryTodos ? ( ) : null} ) : (
暂无历史待办
) ) : null}
) : null} {showAnalyticsCard ? ( {analyticsCards.length ? (
{analyticsCards.map((card, index) => { const clickable = supportsAnalyticsDetail(card); const chartCard = isChartAnalyticsCard(card); const horizontalColumns = resolveHorizontalColumns(card); const compactMobileCard = isMobileViewport && card.layoutType === "horizontal" && !card.fullRow && horizontalColumns >= 3; const ultraCompactMobileCard = compactMobileCard && horizontalColumns >= 4; const metricVisual = getAnalyticsMetricVisual(card, index); const metricFootnote = getAnalyticsMetricFootnote(card); const MetricIcon = metricVisual.icon; const cardSummary = getAnalyticsCardPreviewSummary(card); return (
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)} overflow-hidden border border-slate-100 bg-white text-left shadow-sm transition-all ${ clickable ? "cursor-pointer hover:-translate-y-0.5 hover:shadow-md" : "" } ${ chartCard ? compactMobileCard ? "rounded-[24px] p-4" : "rounded-[32px] p-6" : ultraCompactMobileCard ? "rounded-[18px] p-3" : compactMobileCard ? "rounded-[20px] p-4" : "rounded-[24px] p-5" }`} > {chartCard ? ( <>

{card.title || "未命名卡片"}

{card.subtitle ? (

{card.subtitle}

) : null}
{cardSummary ? (
{cardSummary}
) : null} ) : ( <>

{card.title || "未命名卡片"}

{card.valueText || card.value || "0"}

{metricFootnote ? (
{metricFootnote.tone === "up" ? : null} {metricFootnote.tone === "down" ? : null} {metricFootnote.text}
) : card.subtitle ? (

{card.subtitle}

) : null} )}
); })}
) : (
{home.analyticsPanel?.emptyStateText || "暂无可展示的经营分析卡片"}
)}
) : null} {showActivityCard ? (

最新动态

追踪最近业务流转和状态变化

{visibleActivities.length} 条动态
{visibleActivities.map((news: DashboardActivity, i: number) => ( ))}
{hasMoreActivities && !showAllActivities ? ( ) : null}
) : null}
)} {detailCard ? (
event.stopPropagation()} >

{detailCard.title || "经营分析详情"}

{detailCard.subtitle ? (

{detailCard.subtitle}

) : null}
{detailLoading ? (
正在加载完整数据...
) : detailError ? (
{detailError}
) : (
)}
) : null}
); } function DashboardSkeleton() { return (
{[0, 1, 2, 3].map((item) => (
))}
{[0, 1].map((item) => (
))}
{[0, 1, 2].map((item) => (
))}
); } 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; }