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";
|
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;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
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();
|
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-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"
|
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 ? (
|
|
|
|
|
|
<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
|
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;
|
|
|
|
|
|
}
|