diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57f4a9e..328c92f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy } from "react"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { App as AntApp } from "antd"; +import { App as AntApp, ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; import { ToastProvider } from "./components/ui/Toast"; import { AuthProvider } from "./contexts/AuthContext"; @@ -25,7 +26,8 @@ const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPa export default function App() { return ( - + + @@ -57,6 +59,7 @@ export default function App() { - + + ); } diff --git a/frontend/src/pages/admin/AdminPeoplePage.tsx b/frontend/src/pages/admin/AdminPeoplePage.tsx index 719fca1..437349e 100644 --- a/frontend/src/pages/admin/AdminPeoplePage.tsx +++ b/frontend/src/pages/admin/AdminPeoplePage.tsx @@ -1,18 +1,88 @@ -/** 人员看板 — 按负责人聚合当前在制品设备 */ +/** 人员看板 — 实时在制品 + 历史工时台账 */ import { useEffect, useMemo, useState } from "react"; import { - Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw, + Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw, Clock, Download, } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { fetchPeopleWorkload, type PersonWorkload } from "../../services/dashboardApi"; +import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import dayjs, { type Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +dayjs.locale("zh-cn"); +import { + fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory, + type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery, +} from "../../services/dashboardApi"; +import { getTask } from "../../services/taskApi"; +import type { TaskRecordResponse } from "../../types/api"; + +const { RangePicker } = DatePicker; const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#6366f1"]; +// ─── 滞留时长 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 >= 24) return `${Math.round(h / 24)}天`; + if (h >= 1) return `${h}小时`; + return `${Math.round(h * 60)}分钟`; +} + +// ─── 图片 URL 拼接(对齐 TaskFlowView) ────────────────── +function imageUrl(u: string) { + if (!u) return ""; + if (u.startsWith("http")) return u; + const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); + const path = u.startsWith("/") ? u : "/" + u; + if (path.startsWith("/api/")) { + const origin = base.replace(/\/api(\/v\d+)?$/, ""); + return origin + path; + } + return base + path; +} + +// ─── 任务状态徽章 ─────────────────────────────────────── +const STATUS_MAP: Record = { + WIP: { label: "进行中", cls: "bg-blue-100 text-blue-700" }, + PENDING: { label: "待接收", cls: "bg-amber-100 text-amber-700" }, + COMPLETED: { label: "已完成", cls: "bg-green-100 text-green-700" }, +}; + export default function AdminPeoplePage() { + const [mode, setMode] = useState<"realtime" | "history">("realtime"); + + // 实时 const [workloads, setWorkloads] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); + + // 历史 + const [records, setRecords] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + const [historyRange, setHistoryRange] = useState<[Dayjs, Dayjs] | null>(null); + const [pageSize, setPageSize] = useState(50); + const [fAssignee, setFAssignee] = useState(""); + const [fSpec, setFSpec] = useState(""); + const [fSn, setFSn] = useState(""); + const [fTask, setFTask] = useState(""); + const [recordDetail, setRecordDetail] = useState<{ open: boolean; taskName: string; records: TaskRecordResponse[] }>({ + open: false, taskName: "", records: [], + }); + // 已读记录数(task_id -> 已读的 record_count),用于「新流转」红点标注 + const [readCounts, setReadCounts] = useState>(() => { + try { + return JSON.parse(localStorage.getItem("people_history_read_counts") || "{}"); + } catch { + return {}; + } + }); + const navigate = useNavigate(); const load = () => { @@ -23,12 +93,100 @@ export default function AdminPeoplePage() { .finally(() => setLoading(false)); }; + const buildQuery = (range: [Dayjs, Dayjs] | null): PeopleHistoryQuery => { + const q: PeopleHistoryQuery = {}; + if (range) { + q.since = range[0].startOf("day").toISOString(); + q.until = range[1].endOf("day").toISOString(); + } + if (fAssignee.trim()) q.assignee_id = fAssignee.trim(); + if (fSpec.trim()) q.spec_model = fSpec.trim(); + if (fSn.trim()) q.product_sn = fSn.trim(); + if (fTask.trim()) q.task_name = fTask.trim(); + return q; + }; + + const loadHistory = (range: [Dayjs, Dayjs] | null) => { + setHistoryLoading(true); + fetchPeopleHistory(buildQuery(range)) + .then((data) => { setRecords(data); setHistoryError(null); }) + .catch(() => setHistoryError("加载工时台账失败")) + .finally(() => setHistoryLoading(false)); + }; + + const handleExport = async () => { + try { + await downloadPeopleHistory(buildQuery(historyRange)); + } catch { + setHistoryError("导出失败,请稍后重试"); + } + }; + + const openRecordDetail = async (record: PersonHistoryRecord) => { + try { + const task = await getTask(record.task_id); + setRecordDetail({ + open: true, + taskName: record.task_name || record.task_id, + records: task.records || [], + }); + // 标记该任务为已读(记录数同步) + const next = { ...readCounts, [record.task_id]: record.record_count }; + setReadCounts(next); + localStorage.setItem("people_history_read_counts", JSON.stringify(next)); + } catch { + setHistoryError("加载记录详情失败"); + } + }; + + const handleReset = () => { + setFAssignee(""); + setFSpec(""); + setFSn(""); + setFTask(""); + setHistoryRange(null); + loadHistory(null); + }; + + const markAllRead = () => { + const next: Record = {}; + for (const r of records) next[r.task_id] = r.record_count; + setReadCounts(next); + localStorage.setItem("people_history_read_counts", JSON.stringify(next)); + }; + useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const totalDevices = useMemo( - () => workloads.reduce((sum, w) => sum + w.device_count, 0), - [workloads], - ); + // 自动搜索:模式切换 / 筛选条件 / 时间范围变化时触发(防抖 300ms) + useEffect(() => { + if (mode !== "history") return; + const timer = setTimeout(() => loadHistory(historyRange), 300); + return () => clearTimeout(timer); + }, [mode, fAssignee, fSpec, fSn, fTask, historyRange]); // eslint-disable-line react-hooks/exhaustive-deps + + const totalDevices = useMemo(() => { + const uniqueIds = new Set(); + for (const w of workloads) { + for (const d of w.devices) uniqueIds.add(d.product_id); + } + return uniqueIds.size; + }, [workloads]); + + const totalHistoryDevices = useMemo(() => { + const uniqueIds = new Set(); + for (const r of records) uniqueIds.add(r.product_sn); + return uniqueIds.size; + }, [records]); + + // 搜索下拉选项(从当前结果去重提取) + const assigneeOptions = useMemo(() => { + const seen = new Map(); + for (const r of records) if (r.assignee_id && !seen.has(r.assignee_id)) seen.set(r.assignee_id, r.assignee_name || r.assignee_id); + return Array.from(seen.entries()).map(([id, name]) => ({ value: id, label: `${name} (${id})` })); + }, [records]); + const specOptions = useMemo(() => Array.from(new Set(records.map(r => r.spec_model).filter(Boolean))).map(v => ({ value: v })), [records]); + const snOptions = useMemo(() => Array.from(new Set(records.map(r => r.product_sn).filter(Boolean))).map(v => ({ value: v })), [records]); + const taskOptions = useMemo(() => Array.from(new Set(records.map(r => r.task_name).filter(Boolean))).map(v => ({ value: v })), [records]); function toggle(id: string) { setExpanded((prev) => { @@ -39,115 +197,331 @@ export default function AdminPeoplePage() { }); } + // ─── 历史表格列 ─────────────────────────────────────── + const columns: ColumnsType = [ + { + title: "状态", dataIndex: "status", key: "status", width: 100, + render: (s: string, record: PersonHistoryRecord) => { + const cfg = STATUS_MAP[s] || { label: s, cls: "bg-gray-100 text-gray-600" }; + const unread = record.record_count > (readCounts[record.task_id] || 0); + return ( + + {cfg.label} + {unread && } + + ); + }, + }, + { title: "负责人", dataIndex: "assignee_name", key: "assignee_name", width: 110 }, + { title: "设备身份证", dataIndex: "product_sn", key: "product_sn", width: 190, render: (v: string) => {v} }, + { title: "规格型号", dataIndex: "spec_model", key: "spec_model", width: 130, render: (v: string) => v || "—" }, + { title: "任务名", dataIndex: "task_name", key: "task_name", ellipsis: true }, + { title: "开始时间", dataIndex: "received_at", key: "received_at", width: 120, render: (v: string) => v || "—" }, + { title: "结束时间", dataIndex: "completed_at", key: "completed_at", width: 120, render: (v: string) => v || 进行中 }, + { + title: "总耗时", dataIndex: "duration_hours", key: "duration_hours", width: 120, + sorter: (a, b) => a.duration_hours - b.duration_hours, + render: (h: number) => ( + + {durationLabel(h)} + + ), + }, + { + title: "最新动态/备注", dataIndex: "latest_valid_remark", key: "latest_valid_remark", + render: (_: unknown, record: PersonHistoryRecord) => ( +
+ {record.latest_valid_remark || "—"} + {record.record_count > 0 && ( + { e.stopPropagation(); openRecordDetail(record); }} + > + ({record.record_count}条) + + )} +
+ ), + }, + ]; + return (
{/* 页头 */}

👥 人员看板

-

按负责人聚合当前在制品设备 · 实时快照

+

实时在制品 / 历史工时台账

- + setMode(e.target.value)} + size="small" + optionType="button" + buttonStyle="solid" + > + 实时 + 历史 +
- {/* 统计概览 */} -
-
-
- -

在岗人员

+ {/* ═══ 实时模式 ═══ */} + {mode === "realtime" && ( + <> +
+
+
+ +

当前负责人

+
+

{workloads.length}

+

当前有在制品的负责人

+
+
+
+ +

在制品设备总量

+
+

{totalDevices}

+

按唯一产品去重统计;并发设备会在多个负责人名下各显示一次

+
-

{workloads.length}

-

持有在制品的负责人数

-
-
-
- -

在制品设备总量

-
-

{totalDevices}

-

一台设备若被多人并发处理,会在多人名下各计一次

-
-
- {/* 加载 / 错误 / 空态 */} - {loading && ( -
- -
- )} - {!loading && error && ( -
- {error} -
- )} - {!loading && !error && workloads.length === 0 && ( -
🎉 当前无人在制品
- )} + {loading && ( +
+ +
+ )} + {!loading && error && ( +
+ {error} +
+ )} + {!loading && !error && workloads.length === 0 && ( +
🎉 当前无人在制品
+ )} - {/* 人员列表 */} - {!loading && !error && workloads.length > 0 && ( -
- {workloads.map((w) => { - const isOpen = expanded.has(w.assignee_id); - const initial = (w.assignee_name || w.assignee_id || "?").charAt(0); - const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length]; - return ( -
- {/* 人员头部 */} - + {!loading && !error && workloads.length > 0 && ( +
+ {workloads.map((w) => { + const isOpen = expanded.has(w.assignee_id); + const initial = (w.assignee_name || w.assignee_id || "?").charAt(0); + const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length]; + return ( +
+ - {/* 展开明细 */} - {isOpen && ( -
- {w.devices.length === 0 ? ( -

无设备

- ) : ( -
- {w.devices.map((d) => ( -
navigate(`/admin/tasks?sn=${d.serial_number}`)} - className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md" - > - - {d.task_status === "WIP" ? "进行中" : "待接收"} - - {d.material_name || "未知设备"} - {d.spec_model || "无规格"} - 序列号: {d.external_serial || "未录入"} - 身份证: {d.serial_number} + {isOpen && ( +
+ {w.devices.length === 0 ? ( +

无设备

+ ) : ( +
+ {w.devices.map((d) => ( +
navigate(`/admin/tasks?sn=${d.serial_number}`)} + className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md" + > + + {d.task_status === "WIP" ? "进行中" : "待接收"} + + {d.material_name || "未知设备"} + {d.spec_model || "无规格"} + 序列号: {d.external_serial || "未录入"} + 身份证: {d.serial_number} + + + + {durationLabel(d.duration_hours)} + + {d.received_at && {d.received_at}} + +
+ ))}
- ))} + )}
)}
- )} -
- ); - })} -
+ ); + })} +
+ )} + )} + + {/* ═══ 历史模式(工时台账) ═══ */} + {mode === "history" && ( + <> + {/* 概览 */} +
+
+
+ +

生产设备总量

+
+

{totalHistoryDevices}

+

按唯一身份证去重统计

+
+
+
+ +

工序记录

+
+

{records.length}

+

含进行中与已完成

+
+
+ + {/* 搜索栏 */} +
+
+ setHistoryRange(dates as [Dayjs, Dayjs] | null)} + disabledDate={(d) => d.isAfter(dayjs(), "day")} + style={{ width: 240 }} + placeholder={["开始", "结束"]} + /> + + + + +