1. product_service.py: 删除函数内两处 from sqlalchemy import or_ Python解析器看到函数内的import就把or_当局部变量, 在赋值前使用就报UnboundLocalError 模块顶部已有 import or_, 函数内无需重复导入 2. AdminDashboard.tsx: Drawer width→size (Antd v6 deprecation)
463 lines
21 KiB
TypeScript
463 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|