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,
|
|
|
|
|
|
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,
|
|
|
|
|
|
type DashboardActivity,
|
|
|
|
|
|
type DashboardAnalyticsCard,
|
|
|
|
|
|
type DashboardHome,
|
|
|
|
|
|
type DashboardStat,
|
|
|
|
|
|
type DashboardTodo
|
|
|
|
|
|
} from "@/lib/auth";
|
2026-03-30 06:29:32 +00:00
|
|
|
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
|
|
|
|
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
2026-04-30 07:06:53 +00:00
|
|
|
|
import DashboardAnalyticsChart from "@/components/dashboard/DashboardAnalyticsChart";
|
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();
|
2026-03-30 06:29:32 +00:00
|
|
|
|
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 [completingTodoId, setCompletingTodoId] = useState<string | 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);
|
|
|
|
|
|
}, [home.activities, home.todos]);
|
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 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[];
|
|
|
|
|
|
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;
|
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
|
|
|
|
|
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"
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0 }}
|
2026-03-27 01:16:04 +00:00
|
|
|
|
animate={{ opacity: 1 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
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"
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0 }}
|
2026-03-27 01:16:04 +00:00
|
|
|
|
animate={{ opacity: 1 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
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`}>
|
2026-04-16 02:55:37 +00:00
|
|
|
|
{showTodoCard ? (
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<motion.div
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="crm-dashboard-panel-meta">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
共 {pendingTodos.length + historyTodos.length} 条
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
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
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
{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;
|
|
|
|
|
|
}
|