Files
track/frontend/src/pages/admin/AdminDashboard.tsx
duxingchen 658fc28b9b feat: 管理看板全面改版 — 增强可读性 + 填充空白区域
后端增强:
  - DashboardStats 新增 tasks_rejected, tasks_rework, unread_notifications
  - 新增 GET /dashboard/recent-activity 最近动态端点
  - 关联 Task + Product 表返回完整动态信息

前端改版 (AdminDashboard.tsx):
  - 4 张概览卡片: 产品流转 | 任务状态 | 品质通知 | 完成率
  - 每张卡片含进度条(百分比标注) + 中文说明
  - SVG 环形图展示任务完成率
  - 最近流转动态时间线 (8条)
  - 快捷入口: 创建产品/任务管理/打印配置/扫码干活
  - 底部说明卡片解释"产品 vs 任务"的区别
  - 响应式网格填满全屏, 无空白区域
2026-08-12 13:25:49 +08:00

263 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, Plus, ArrowRight,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
fetchDashboardStats, fetchRecentActivity,
type DashboardStats, type RecentActivity,
} from "../../services/dashboardApi";
// ─── 小卡片 ───────────────────────────────────────────────
function MiniStat({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div className="text-center">
<span className={`text-xl font-bold ${color}`}>{value}</span>
<p className="text-[11px] text-gray-400">{label}</p>
</div>
);
}
// ─── 进度条 ───────────────────────────────────────────────
function ProgressBar({ a, b, c, total, labels }: {
a: number; b: number; c: number; total: number;
labels: [string, string, string];
}) {
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400"></div>;
const pct = (n: number) => Math.round((n / total) * 100);
const segs = [
{ n: a, color: "bg-amber-400", label: labels[0] },
{ n: b, color: "bg-blue-500", label: labels[1] },
{ n: c, color: "bg-emerald-500", label: labels[2] },
].filter(s => s.n > 0);
return (
<div>
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
{segs.map((s, i) => (
<div key={i} className={`${s.color} transition-all duration-500`}
style={{ width: `${(s.n / total) * 100}%` }} />
))}
</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs text-gray-500">
{segs.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label} {s.n}{pct(s.n)}%
</span>
))}
</div>
</div>
);
}
// ─── 动态条目 ─────────────────────────────────────────────
function ActivityItem({ a }: { a: RecentActivity }) {
const iconMap: Record<string, string> = {
"创建任务": "📋", "确认接收": "✅", "完成任务": "🏁", "完工转交": "🔄",
"品质驳回": "❌", "结束分支": "🛑", "撤回转交": "↩️",
};
return (
<div className="flex items-start gap-3 border-b border-gray-50 py-2.5 last:border-0">
<span className="mt-0.5 text-base">{iconMap[a.action] || "📌"}</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-gray-700">{a.action}</span>
<span className="truncate text-xs text-gray-500">{a.task_name}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-gray-400">
<span>{a.operator}</span>
<span>·</span>
<span className="font-mono">{a.product_sn.slice(0, 8)}</span>
<span className="ml-auto">{a.time}</span>
</div>
</div>
</div>
);
}
// ─── 主组件 ───────────────────────────────────────────────
export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [activity, setActivity] = useState<RecentActivity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
Promise.all([
fetchDashboardStats(),
fetchRecentActivity(8),
])
.then(([s, a]) => { setStats(s); setActivity(a); })
.catch(() => setError("加载统计数据失败,请确认后端已启动"))
.finally(() => setLoading(false));
}, []);
// ── 加载态 ──
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}
// ── 错误态 ──
if (error || !stats) {
return (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />{error || "数据为空"}
</div>
);
}
return (
<div className="space-y-6">
{/* ═══ 页头 ═══ */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 </h2>
<p className="mt-0.5 text-sm text-gray-400">
= =
</p>
</div>
<button onClick={() => window.location.reload()}
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{/* ═══ 第1行4 张概览卡片 ═══ */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{/* 产品流转 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
<h3 className="text-sm font-semibold text-gray-700">📦 </h3>
</div>
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> </span></p>
<p className="mb-3 text-[11px] text-gray-400"></p>
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
total={stats.products_total} labels={["待流转", "流转中", "已完成"]} />
</div>
{/* 任务状态 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-purple-600" />
<h3 className="text-sm font-semibold text-gray-700">📋 </h3>
</div>
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> </span></p>
<p className="mb-3 text-[11px] text-gray-400"></p>
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} />
</div>
{/* 品质 & 通知 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
<h3 className="text-sm font-semibold text-gray-700"> & </h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-red-50 p-3 text-center">
<p className="text-2xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
<p className="text-[11px] text-red-500">/</p>
</div>
<div className="rounded-lg bg-blue-50 p-3 text-center">
<p className="text-2xl font-bold text-blue-600">{stats.unread_notifications}</p>
<p className="text-[11px] text-blue-500"></p>
</div>
</div>
<div className="mt-3 flex justify-around border-t border-gray-100 pt-3">
<MiniStat value={stats.tasks_rejected} label="已驳回" color="text-red-600" />
<MiniStat value={stats.tasks_rework} label="返工中" color="text-orange-600" />
<MiniStat value={stats.unread_notifications} label="未读消息" color="text-blue-600" />
</div>
</div>
{/* 完成率 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-emerald-600" />
<h3 className="text-sm font-semibold text-gray-700"> </h3>
</div>
<div className="flex items-end gap-4">
<p className="text-3xl font-bold text-emerald-600">
{stats.tasks_total > 0 ? Math.round((stats.tasks_completed / stats.tasks_total) * 100) : 0}%
</p>
<p className="text-sm text-gray-400">{stats.tasks_completed}/{stats.tasks_total} </p>
</div>
{/* 简易环形图 */}
<div className="mt-4 flex justify-center">
<svg viewBox="0 0 100 100" className="h-20 w-20 -rotate-90">
<circle cx="50" cy="50" r="40" fill="none" stroke="#f3f4f6" strokeWidth="10" />
<circle cx="50" cy="50" r="40" fill="none" stroke="#10b981" strokeWidth="10"
strokeDasharray={`${stats.tasks_total > 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`}
strokeLinecap="round" />
</svg>
</div>
</div>
</div>
{/* ═══ 第2行最近动态 + 快捷入口 ═══ */}
<div className="grid gap-4 lg:grid-cols-3">
{/* 最近动态 */}
<div className="rounded-xl bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">🕐 </h3>
<span className="text-[11px] text-gray-400"> 8 </span>
</div>
{activity.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400"></div>
) : (
<div className="divide-y divide-gray-50">
{activity.map((a, i) => <ActivityItem key={i} a={a} />)}
</div>
)}
</div>
{/* 快捷入口 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<h3 className="mb-4 text-sm font-semibold text-gray-700"> </h3>
<div className="space-y-2">
<button onClick={() => navigate("/admin/products")}
className="flex w-full items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-left text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors">
<span className="flex items-center gap-2"><Plus className="h-4 w-4" /></span>
<ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/admin/tasks")}
className="flex w-full items-center justify-between rounded-lg bg-purple-50 px-4 py-3 text-left text-sm font-medium text-purple-700 hover:bg-purple-100 transition-colors">
<span className="flex items-center gap-2"><ClipboardList className="h-4 w-4" /></span>
<ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/admin/print-config")}
className="flex w-full items-center justify-between rounded-lg bg-amber-50 px-4 py-3 text-left text-sm font-medium text-amber-700 hover:bg-amber-100 transition-colors">
<span className="flex items-center gap-2">🖨 </span>
<ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/scan")}
className="flex w-full items-center justify-between rounded-lg bg-emerald-50 px-4 py-3 text-left text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors">
<span className="flex items-center gap-2">📱 </span>
<ArrowRight className="h-4 w-4" />
</button>
</div>
{/* 说明卡片 */}
<div className="mt-5 rounded-lg bg-gray-50 p-3">
<p className="text-[11px] leading-relaxed text-gray-500">
<strong>💡 </strong><br />
<strong className="text-gray-700"></strong>
<strong className="text-gray-700"></strong>线
<strong className="text-gray-700"></strong>
</p>
</div>
</div>
</div>
</div>
);
}