2026-03-26 09:29:55 +00:00
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { BarChart3, Building2, Check, TrendingUp, Users } 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-03-26 09:29:55 +00:00
|
|
|
|
import { completeDashboardTodo, getDashboardHome, type DashboardActivity, 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-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",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
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-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-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;
|
|
|
|
|
|
|
|
|
|
|
|
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-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-15 10:11:16 +00:00
|
|
|
|
<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);
|
2026-04-15 10:15:06 +00:00
|
|
|
|
const valueClassName = getStatValueClass(display.value);
|
2026-04-15 10:11:16 +00:00
|
|
|
|
|
|
|
|
|
|
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 }}
|
2026-04-15 10:15:06 +00:00
|
|
|
|
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"
|
2026-04-15 10:11:16 +00:00
|
|
|
|
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" />
|
2026-04-15 10:15:06 +00:00
|
|
|
|
<div className="flex h-full flex-col justify-between gap-3 min-[520px]:gap-4">
|
2026-04-15 10:11:16 +00:00
|
|
|
|
<div className="flex items-start gap-3 sm:gap-4">
|
2026-04-15 10:15:06 +00:00
|
|
|
|
<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`} />
|
2026-04-15 10:11:16 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 pt-0.5">
|
2026-04-15 10:15:06 +00:00
|
|
|
|
<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">
|
2026-04-15 10:11:16 +00:00
|
|
|
|
{stat.name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-end justify-between gap-2 min-[520px]:gap-3">
|
|
|
|
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
2026-04-15 10:15:06 +00:00
|
|
|
|
<p className={`overflow-hidden text-ellipsis whitespace-nowrap font-bold leading-none tracking-[-0.05em] text-slate-900 dark:text-white ${valueClassName}`}>
|
2026-04-15 10:11:16 +00:00
|
|
|
|
{display.value}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-04-15 10:15:06 +00:00
|
|
|
|
<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">
|
2026-04-15 10:11:16 +00:00
|
|
|
|
{display.unit}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
</div>
|
2026-04-15 10:11:16 +00:00
|
|
|
|
</motion.button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-26 09:29:55 +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
|
|
|
|
<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-03-26 09:29:55 +00:00
|
|
|
|
className="crm-card crm-card-pad-lg rounded-2xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
|
|
|
|
|
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">待办事项</h2>
|
|
|
|
|
|
<span className="text-[11px] text-slate-400 dark:text-slate-500 sm:text-xs">
|
|
|
|
|
|
共 {pendingTodos.length + historyTodos.length} 条
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="crm-section-stack sm:gap-5">
|
|
|
|
|
|
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
|
|
|
|
|
|
<div className="mb-2 flex items-center gap-2 sm:mb-3">
|
|
|
|
|
|
<span className="crm-pill crm-pill-neutral">待办</span>
|
|
|
|
|
|
<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) => (
|
|
|
|
|
|
<li key={task.id} className="group flex items-start gap-2.5 rounded-xl border border-white/80 bg-white px-3 py-2.5 transition-all hover:bg-slate-50 dark:border-slate-700/60 dark:bg-slate-900/40 dark:hover:bg-slate-800 sm:gap-3 sm:p-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void handleCompleteTodo(task.id)}
|
|
|
|
|
|
disabled={completingTodoId === task.id}
|
|
|
|
|
|
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-violet-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-600 dark:hover:border-violet-400"
|
|
|
|
|
|
aria-label={`完成待办:${getTodoDisplayTitle(task)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className="h-3 w-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<p className="truncate text-sm text-slate-700 transition-colors group-hover:text-slate-900 dark:text-slate-300 dark:group-hover:text-white">
|
|
|
|
|
|
{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>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="crm-card-subtle rounded-xl p-3 sm:p-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setHistoryExpanded((current) => !current)}
|
|
|
|
|
|
className="flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-violet-300 hover:bg-violet-50/50 dark:border-slate-700 dark:bg-slate-900/40 dark:hover:border-violet-600 dark:hover:bg-slate-800"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="crm-pill crm-pill-violet">历史</span>
|
|
|
|
|
|
<span className="text-xs text-slate-400 dark:text-slate-500">{historyTodos.length} 条已完成</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
{historyExpanded ? "收起" : "展开"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{historyExpanded ? (
|
|
|
|
|
|
historyTodos.length ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ul className="mt-3 space-y-2.5 sm:space-y-3">
|
|
|
|
|
|
{visibleHistoryTodos.map((task: DashboardTodo) => (
|
|
|
|
|
|
<li key={task.id} className="flex items-start gap-2.5 rounded-xl border border-white/80 bg-white px-3 py-2.5 dark:border-slate-700/60 dark:bg-slate-900/40 sm:gap-3 sm:p-3">
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<p className="truncate text-sm text-slate-500 line-through dark:text-slate-400">
|
|
|
|
|
|
{getTodoDisplayTitle(task)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{hasMoreHistoryTodos ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowAllHistoryTodos((current) => !current)}
|
|
|
|
|
|
className="mt-3 w-full rounded-xl border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-violet-300 hover:text-violet-600 dark:border-slate-700 dark:text-slate-400 dark:hover:border-violet-700 dark:hover:text-violet-400"
|
|
|
|
|
|
>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
|
|
|
|
|
|
<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-03-26 09:29:55 +00:00
|
|
|
|
className="crm-card crm-card-pad-lg rounded-2xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
|
|
|
|
|
<div className="crm-section-stack sm:gap-5">
|
|
|
|
|
|
{visibleActivities.map((news: DashboardActivity, i: number) => (
|
|
|
|
|
|
<div key={news.id ?? i} className="flex gap-4">
|
|
|
|
|
|
<div className="relative mt-1 flex h-3 w-3 items-center justify-center">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-900 dark:text-white">{news.title || "无"}</p>
|
|
|
|
|
|
<p className="crm-field-note mt-0.5">{news.content || "无"}</p>
|
|
|
|
|
|
<p className="mt-1 text-[10px] text-slate-400 dark:text-slate-500">{news.timeText || "无"}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{hasMoreActivities && !showAllActivities ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowAllActivities(true)}
|
|
|
|
|
|
className="mt-4 w-full rounded-xl border border-dashed border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:border-violet-300 hover:text-violet-600 dark:border-slate-700 dark:text-slate-400 dark:hover:border-violet-700 dark:hover:text-violet-400"
|
|
|
|
|
|
>
|
|
|
|
|
|
...
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</div>
|
2026-03-27 01:16:04 +00:00
|
|
|
|
</motion.div>
|
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;
|
|
|
|
|
|
}
|