unis_crm/frontend/src/pages/Dashboard.tsx

967 lines
44 KiB
TypeScript
Raw Normal View History

2026-03-26 09:29:55 +00:00
import { useEffect, useMemo, useState } from "react";
2026-04-30 07:06:53 +00:00
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";
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";
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;
}>;
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-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);
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<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",
};
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;
}
2026-03-19 06:23:03 +00:00
export default function Dashboard() {
2026-03-27 04:22:00 +00:00
const navigate = useNavigate();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
2026-03-20 08:39:07 +00:00
const [home, setHome] = useState<DashboardHome>({});
2026-03-26 09:29:55 +00:00
const [loading, setLoading] = useState(true);
2026-03-20 08:39:07 +00:00
const [showAllActivities, setShowAllActivities] = useState(false);
2026-03-26 09:29:55 +00:00
const [showAllHistoryTodos, setShowAllHistoryTodos] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [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-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;
const analyticsCards = (home.analyticsPanel?.cards ?? []).filter((item) => !item.errorMessage);
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);
};
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";
2026-03-19 06:23:03 +00:00
return (
2026-03-26 09:29:55 +00:00
<div className="crm-page-stack">
2026-03-27 09:05:41 +00:00
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle">
2026-04-02 02:07:21 +00:00
{home.realName || "无"}
2026-03-27 09:05:41 +00:00
</p>
</div>
2026-03-19 06:23:03 +00:00
</header>
2026-03-26 09:29:55 +00:00
{loading ? (
2026-03-27 01:16:04 +00:00
<motion.div
key="dashboard-skeleton"
initial={disableMobileMotion ? false : { opacity: 0 }}
2026-03-27 01:16:04 +00:00
animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.22, ease: "easeOut" }}
2026-03-27 01:16:04 +00:00
>
<DashboardSkeleton />
</motion.div>
2026-03-26 09:29:55 +00:00
) : (
2026-03-27 01:16:04 +00:00
<motion.div
key="dashboard-content"
initial={disableMobileMotion ? false : { opacity: 0 }}
2026-03-27 01:16:04 +00:00
animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
2026-03-27 01:16:04 +00:00
>
2026-04-30 07:06:53 +00:00
{showStatsCard ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-4 min-[520px]:grid-cols-2 sm:gap-4 xl:grid-cols-4">
{stats.map((stat, i) => {
const display = formatStatDisplay(stat.metricKey, stat.value);
const valueClassName = getStatValueClass(display.value);
return (
<motion.button
key={stat.name}
type="button"
onClick={() => handleStatCardClick(stat.metricKey)}
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
className="crm-card group min-h-[104px] overflow-hidden rounded-xl border border-slate-200/80 bg-white/95 p-3 text-left shadow-[0_14px_40px_-28px_rgba(15,23,42,0.35)] transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_44px_-24px_rgba(109,40,217,0.22)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 min-[520px]:min-h-[128px] sm:rounded-2xl sm:p-5"
whileTap={disableMobileMotion ? undefined : { scale: 0.98 }}
aria-label={`查看${stat.name}`}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-violet-200/70 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className="flex h-full flex-col justify-between gap-3 min-[520px]:gap-4">
<div className="flex items-start gap-3 sm:gap-4">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${stat.bg} shadow-inner shadow-white/60 min-[400px]:h-10 min-[400px]:w-10 min-[520px]:h-12 min-[520px]:w-12`}>
<stat.icon className={`h-4.5 w-4.5 ${stat.color} min-[400px]:h-5 min-[400px]:w-5 min-[520px]:h-6 min-[520px]:w-6`} />
</div>
<div className="min-w-0 pt-0.5">
<p className="max-h-10 overflow-hidden text-[11px] font-semibold leading-5 text-slate-500 dark:text-slate-400 min-[400px]:text-xs min-[520px]:text-sm">
{stat.name}
</p>
</div>
2026-04-15 10:11:16 +00:00
</div>
2026-04-30 07:06:53 +00:00
<div className="flex items-end justify-between gap-2 min-[520px]:gap-3">
<div className="min-w-0 flex-1 overflow-hidden">
<p className={`overflow-hidden text-ellipsis whitespace-nowrap font-bold leading-none tracking-[-0.05em] text-slate-900 dark:text-white ${valueClassName}`}>
{display.value}
</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-1 text-[10px] font-medium tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-300 min-[430px]:px-2 min-[520px]:px-2.5 min-[520px]:text-xs">
{display.unit}
</span>
2026-04-15 10:11:16 +00:00
</div>
</div>
2026-04-30 07:06:53 +00:00
</motion.button>
);
})}
</div>
) : null}
2026-03-26 09:29:55 +00:00
2026-04-30 07:06:53 +00:00
<div className={`${showStatsCard ? "mt-5 md:mt-6 " : ""}grid gap-5 ${dashboardPanelGridClassName} md:gap-6`}>
2026-04-16 02:55:37 +00:00
{showTodoCard ? (
2026-03-26 09:29:55 +00:00
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
2026-03-26 09:29:55 +00:00
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.4 }}
2026-04-30 07:06:53 +00:00
className="overflow-hidden rounded-[30px] border border-slate-100 bg-white p-5 shadow-[0_16px_38px_-30px_rgba(15,23,42,0.14)]"
2026-03-26 09:29:55 +00:00
>
2026-04-30 07:06:53 +00:00
<div className="crm-dashboard-panel-header">
<div className="crm-dashboard-panel-heading">
<h2 className="crm-dashboard-panel-title"></h2>
<p className="crm-dashboard-panel-subtitle"></p>
</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 ? (
<div className={`grid grid-cols-12 gap-3 min-[420px]:gap-4 ${isMobileViewport ? "" : "sm:gap-4"}`}>
{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 (
<div
key={card.cardKey || card.id || card.title}
onClick={() => handleAnalyticsCardClick(card)}
onKeyDown={(event) => {
if (!clickable) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleAnalyticsCardClick(card);
}
}}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
className={`${getAnalyticsCardLayoutClass(card)} 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 ? (
<>
<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>
<span className={`${compactMobileCard ? "p-1.5" : "p-2"} rounded-xl text-slate-300`}>
<MoreVertical className="h-4 w-4" />
</span>
</div>
<DashboardAnalyticsChart card={card} />
{cardSummary ? (
<div className={`${compactMobileCard ? "mt-4 pt-3 text-[10px]" : "mt-5 pt-4 text-[11px]"} border-t border-slate-100 text-center text-slate-400`}>
{cardSummary}
</div>
) : null}
</>
) : (
<>
<div className={`${
ultraCompactMobileCard
? "mb-3 h-8 w-8 rounded-xl"
: compactMobileCard
? "mb-3 h-9 w-9 rounded-2xl"
: "mb-4 h-10 w-10 rounded-2xl"
} flex items-center justify-center ${metricVisual.backgroundClassName}`}>
<MetricIcon className={`${
ultraCompactMobileCard ? "h-4 w-4" : "h-[18px] w-[18px]"
} ${metricVisual.iconClassName}`} />
</div>
<p className={`${
ultraCompactMobileCard ? "text-[9px] leading-4" : "text-[10px]"
} line-clamp-2 font-bold uppercase tracking-wider text-slate-400`}>
{card.title || "未命名卡片"}
</p>
<h3 className={`${
ultraCompactMobileCard ? "mt-1 text-lg leading-tight" : compactMobileCard ? "mt-1 text-[19px]" : "mt-1 text-xl"
} font-bold text-slate-900`}>
{card.valueText || card.value || "0"}
</h3>
{metricFootnote ? (
<div
className={`${
ultraCompactMobileCard ? "mt-1.5 text-[9px]" : "mt-2 text-[10px]"
} inline-flex items-center gap-0.5 font-bold ${
metricFootnote.tone === "up"
? "text-emerald-500"
: metricFootnote.tone === "down"
? "text-rose-500"
: "text-slate-400"
}`}
>
{metricFootnote.tone === "up" ? <ArrowUpRight className="h-3 w-3" /> : null}
{metricFootnote.tone === "down" ? <ArrowDownRight className="h-3 w-3" /> : null}
<span>{metricFootnote.text}</span>
</div>
) : card.subtitle ? (
<p className={`${ultraCompactMobileCard ? "mt-1.5 text-[9px]" : "mt-2 text-[10px]"} font-medium text-slate-400`}>
{card.subtitle}
</p>
) : null}
</>
)}
</div>
);
})}
</div>
) : (
<div className="crm-empty-panel">
{home.analyticsPanel?.emptyStateText || "暂无可展示的经营分析卡片"}
</div>
)}
</motion.div>
) : null}
2026-04-16 02:55:37 +00:00
{showActivityCard ? (
2026-03-26 09:29:55 +00:00
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
2026-03-26 09:29:55 +00:00
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.5 }}
2026-04-30 07:06:53 +00:00
className="overflow-hidden rounded-[30px] border border-slate-100 bg-white p-5 shadow-[0_16px_38px_-30px_rgba(15,23,42,0.14)]"
2026-03-26 09:29:55 +00:00
>
2026-04-30 07:06:53 +00:00
<div className="crm-dashboard-panel-header">
<div className="crm-dashboard-panel-heading">
<h2 className="crm-dashboard-panel-title"></h2>
<p className="crm-dashboard-panel-subtitle"></p>
</div>
<span className="crm-dashboard-panel-meta">{visibleActivities.length} </span>
</div>
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
<div className="crm-section-stack sm:gap-5">
2026-03-26 09:29:55 +00:00
{visibleActivities.map((news: DashboardActivity, i: number) => (
2026-04-16 02:55:37 +00:00
<button
key={news.id ?? i}
type="button"
onClick={() => handleActivityClick(news)}
disabled={!news?.bizId || !news?.bizType}
2026-04-30 07:06:53 +00:00
className="group flex w-full gap-4 rounded-[20px] border border-slate-100 bg-white px-4 py-3 text-left transition-colors hover:border-slate-200 hover:bg-slate-50/70 disabled:cursor-default disabled:hover:border-slate-100 disabled:hover:bg-white"
2026-04-16 02:55:37 +00:00
>
<div className="relative mt-1 flex h-3 w-3 shrink-0 items-center justify-center">
2026-03-26 09:29:55 +00:00
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-20"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500"></span>
</div>
2026-04-16 02:55:37 +00:00
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
2026-04-30 07:06:53 +00:00
<p className="text-sm font-semibold text-slate-900 transition-colors group-hover:text-slate-700">
2026-04-16 02:55:37 +00:00
{news.title || "无"}
</p>
{news.bizId ? (
2026-04-30 07:06:53 +00:00
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
2026-04-16 02:55:37 +00:00
</span>
) : null}
</div>
<p className="crm-field-note mt-0.5 line-clamp-2">{news.content || "无"}</p>
2026-04-30 07:06:53 +00:00
<div className="mt-1 flex items-center gap-2 text-[10px] text-slate-400">
2026-04-16 02:55:37 +00:00
<span>{news.timeText || "无"}</span>
{news.operatorName ? <span>· {news.operatorName}</span> : null}
</div>
2026-03-26 09:29:55 +00:00
</div>
2026-04-16 02:55:37 +00:00
</button>
2026-03-26 09:29:55 +00:00
))}
2026-04-30 07:06:53 +00:00
</div>
{hasMoreActivities && !showAllActivities ? (
<button
type="button"
onClick={() => setShowAllActivities(true)}
className="mt-4 w-full rounded-[18px] border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
...
</button>
) : null}
2026-03-26 09:29:55 +00:00
</div>
</motion.div>
2026-04-16 02:55:37 +00:00
) : null}
2026-03-26 09:29:55 +00:00
</div>
2026-03-27 01:16:04 +00:00
</motion.div>
2026-03-26 09:29:55 +00:00
)}
2026-04-30 07:06:53 +00:00
{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;
}