Files
track/frontend/src/pages/admin/AdminDashboard.tsx
duxingchen 49cbf22854 fix: 修复or_局部导入遮蔽模块级导入导致UnboundLocalError + Drawer改用size
1. product_service.py: 删除函数内两处 from sqlalchemy import or_
   Python解析器看到函数内的import就把or_当局部变量, 在赋值前使用就报UnboundLocalError
   模块顶部已有 import or_, 函数内无需重复导入

2. AdminDashboard.tsx: Drawer width→size (Antd v6 deprecation)
2026-08-12 15:05:39 +08:00

463 lines
21 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, useCallback } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages,
type DashboardStats, type WipTask, type ProductMessageItem,
} from "../../services/dashboardApi";
const { RangePicker } = DatePicker;
// ─── 时间筛选选项 ─────────────────────────────────────────
type DateRangeKey = "today" | "7d" | "30d" | "custom";
function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
if (key === "custom" && customRange) {
return {
since: customRange[0].startOf("day").toISOString(),
until: customRange[1].endOf("day").toISOString(),
};
}
const since = dayjs().startOf("day");
if (key === "7d") return { since: since.subtract(7, "day").toISOString() };
if (key === "30d") return { since: since.subtract(30, "day").toISOString() };
// today
return { since: since.toISOString() };
}
// ─── 进度条(支持3或4段) ─────────────────────────────────
function ProgressBar({ a, b, c, total, labels, d }: {
a: number; b: number; c: number; total: number;
labels: [string, string, string];
d?: number;
}) {
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] },
...(d !== undefined ? [{ n: d, color: "bg-red-400", label: "已驳回" }] : []),
].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>
);
}
// ─── 滞留时间 SLA 预警颜色 ────────────────────────────────
function durationColor(h: number) {
if (h >= 48) return "text-red-600 bg-red-100 font-bold";
if (h >= 24) return "text-orange-600 bg-orange-50";
return "text-emerald-600 bg-emerald-50";
}
function durationLabel(h: number) {
if (h >= 48) return `${Math.round(h / 24)}天`;
if (h >= 24) return `${Math.round(h / 24)}天`;
if (h >= 1) return `${h}小时`;
return `${Math.round(h * 60)}分钟`;
}
function WipRow({ t }: { t: WipTask }) {
const nav = useNavigate();
const handleClick = () => {
if (t.product_sn) {
nav(`/admin/tasks?sn=${t.product_sn}`);
}
};
return (
<div
onClick={handleClick}
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-blue-200 hover:shadow-md"
>
{/* 主信息行:状态 + 任务名 + 负责人 */}
<div className="flex items-center gap-2">
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"
}`}>
{t.status === "WIP" ? "进行中" : "待接收"}
</span>
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
<span className="text-xs text-gray-500">|</span>
<span className="text-xs text-gray-500 shrink-0">负责人: {t.assignee}</span>
{/* 滞留时间 */}
<span className={`ml-auto shrink-0 rounded-md px-2 py-0.5 text-xs font-bold ${durationColor(t.duration_hours)}`}>
<Clock className="mr-0.5 inline h-3 w-3" />
{durationLabel(t.duration_hours)}
</span>
</div>
{/* 附加信息行 */}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
<span className="text-gray-300">|</span>
<span>{t.spec_model || "无规格"}</span>
<span className="text-gray-300">|</span>
<span>序列号: {t.external_serial || "未录入"}</span>
<span className="text-gray-300">|</span>
<span className="font-mono text-gray-300">身份证: {t.product_sn}</span>
{t.received_at && <span className="ml-auto">{t.received_at}</span>}
</div>
</div>
);
}
// ─── 留言列表项(卡片式) ─────────────────────────────────
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4"];
function MsgRow({ m }: { m: ProductMessageItem }) {
const t = m.created_at ? dayjs(m.created_at).format("YYYY-MM-DD HH:mm") : "";
const initial = (m.operator_name || "?").charAt(0);
const color = AVATAR_COLORS[initial.charCodeAt(0) % AVATAR_COLORS.length];
return (
<div className="mb-3 rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-shadow hover:shadow-md">
<div className="flex gap-3">
{/* 头像 */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{m.operator_name}</span>
<span className="shrink-0 text-xs text-gray-400">{t}</span>
</div>
<p className="mb-2 text-sm leading-relaxed text-gray-600">{m.content}</p>
<div className="flex flex-col gap-0.5">
{m.material_name && (
<span className="text-xs">
<span className="font-semibold text-gray-700">{m.material_name}</span>
<span className="text-gray-400"> · 序列号: </span>
<span className="font-medium text-gray-600">{m.external_serial || "未录入"}</span>
</span>
)}
<span className="text-[10px] text-gray-300 font-mono">身份证: {m.product_sn}</span>
</div>
</div>
</div>
</div>
);
}
// ─── 主组件 ───────────────────────────────────────────────
export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 时间筛选
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
// 留言抽屉
const [msgDrawerOpen, setMsgDrawerOpen] = useState(false);
const [msgKeyword, setMsgKeyword] = useState("");
const [msgData, setMsgData] = useState<ProductMessageItem[]>([]);
const [msgTotal, setMsgTotal] = useState(0);
const [msgLoading, setMsgLoading] = useState(false);
const navigate = useNavigate();
// ── 加载主数据 ──
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => {
setLoading(true);
const { since, until } = rangeToParams(key, range);
Promise.all([
fetchDashboardStats(since, until),
fetchWipTasks(20),
])
.then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
.catch(() => setError("加载失败,请确认后端已启动"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { loadData(dateKey, customRange); }, [dateKey, customRange]);
// ── 加载留言 ──
const loadMessages = useCallback(async (kw: string) => {
setMsgLoading(true);
try {
const res = await fetchDashboardMessages(kw, 0, 50);
setMsgData(res.items);
setMsgTotal(res.total);
} catch { /* ignore */ }
finally { setMsgLoading(false); }
}, []);
const openMsgDrawer = () => {
setMsgDrawerOpen(true);
setMsgKeyword("");
loadMessages("");
};
const onMsgSearch = (value: string) => {
setMsgKeyword(value);
loadMessages(value);
};
// ── 加载态 ──
if (loading && !stats) {
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 || "数据为空"}
<button onClick={() => loadData(dateKey, customRange)} className="ml-auto text-blue-600 underline">重试</button>
</div>
);
}
return (
<div className="space-y-6">
{/* ═══ 页头 + 时间筛选器 ═══ */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 生产管理看板</h2>
<p className="mt-0.5 text-sm text-gray-400">
产品 = 物理实体(身份证)| 任务 = 工序节点
</p>
</div>
<div className="flex items-center gap-3">
{/* 时间筛选 */}
<Radio.Group
value={dateKey}
onChange={e => { setDateKey(e.target.value); setCustomRange(null); }}
size="small"
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="today">今天</Radio.Button>
<Radio.Button value="7d">近7天</Radio.Button>
<Radio.Button value="30d">近30天</Radio.Button>
<Radio.Button value="custom">自定义</Radio.Button>
</Radio.Group>
{dateKey === "custom" && (
<RangePicker
size="small"
value={customRange as any}
onChange={dates => setCustomRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: 240 }}
placeholder={["开始", "结束"]}
/>
)}
<button onClick={() => loadData(dateKey, customRange)}
className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-gray-500 hover:bg-gray-100">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* 提示:已完结受时间筛选 */}
{dateKey !== "today" && (
<div className="rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600">
📐 当前时间筛选仅影响<strong>已完成/已驳回</strong>计数,在制品和总数始终为实时快照
</div>
)}
{/* ═══ 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">PENDING/WIP 实时 | COMPLETED 按时段</p>
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} />
</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-3">
<div className="rounded-lg bg-red-50 p-3 text-center">
<p className="text-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
<p className="text-[11px] text-red-500">驳回/返工</p>
</div>
{/* 留言 — 可点击打开抽屉 */}
<button
onClick={openMsgDrawer}
className="rounded-lg bg-purple-50 p-3 text-center hover:bg-purple-100 transition-colors border-0 cursor-pointer"
>
<p className="text-xl font-bold text-purple-600">{stats.unread_messages}</p>
<p className="flex items-center justify-center gap-1 text-[11px] text-purple-500">
<MessageCircle className="h-3 w-3" />留言 ↗
</p>
</button>
</div>
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<button onClick={() => navigate("/notifications")}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-blue-600">
<Bell className="h-3.5 w-3.5" />
系统通知 {stats.unread_notifications > 0 ? `(${stats.unread_notifications})` : ""} ↗
</button>
</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>
<svg viewBox="0 0 100 100" className="mx-auto mt-4 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>
<p className="mt-1 text-center text-[10px] text-gray-400">基于时间筛选后的已完成数</p>
</div>
</div>
{/* ═══ 在制品 + 快捷入口 ═══ */}
<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="flex items-center gap-2 text-sm font-semibold text-gray-700">
<Clock className="h-4 w-4 text-orange-500" /> 当前在制品(实时)
</h3>
<span className="text-[11px] text-gray-400">共 {wipTasks.length} 个</span>
</div>
{wipTasks.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400">🎉 暂无滞留任务</div>
) : (
<div>
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
<span className="w-14 shrink-0">状态</span>
<span className="flex-1">任务 · 负责人</span>
<span className="w-16 shrink-0 text-right">滞留</span>
</div>
{wipTasks.map((t, i) => <WipRow key={i} t={t} />)}
</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>📦 产品管理</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>📋 任务管理</span><ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/notifications")}
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>🔔 通知中心</span><ArrowRight className="h-4 w-4" />
</button>
<button onClick={openMsgDrawer}
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>💬 协同留言</span><ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* ═══ 留言抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">💬 协同留言板 <span className="font-normal text-gray-400">全厂 · {msgTotal} 条</span></span>}
open={msgDrawerOpen}
onClose={() => setMsgDrawerOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
<Input.Search
placeholder="搜索 SN码 / 物料名称 / 留言人 / 内容"
value={msgKeyword}
onChange={e => setMsgKeyword(e.target.value)}
onSearch={onMsgSearch}
allowClear
onClear={() => onMsgSearch("")}
enterButton
className="mb-4"
/>
{msgLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : msgData.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
{msgKeyword ? "未找到匹配的留言" : "暂无留言记录"}
</div>
) : (
<div>
{msgData.map(m => <MsgRow key={m.id} m={m} />)}
{/* 简易分页 */}
{msgTotal > 30 && (
<div className="mt-4 flex items-center justify-center gap-2">
{Array.from({ length: Math.ceil(msgTotal / 30) }, (_, i) => (
<button
key={i}
onClick={() => {
fetchDashboardMessages(msgKeyword, i * 30, 30)
.then(res => { setMsgData(res.items); setMsgTotal(res.total); })
.catch(() => {});
}}
className="rounded-md border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-100"
>
{i + 1}
</button>
))}
</div>
)}
</div>
)}
</Drawer>
</div>
);
}