0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
- onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
- >
-
0 ? "text-blue-600" : "text-gray-800"}`}>{v}
- {assignees.length > 0 && (
-
- {summarizeAssignees(assignees)}
-
- )}
-
- );
- },
+ // 🔧 两行表头:第一行区段名(生产区 / 售后区 / 完结区),第二行具体工序
+ ...groups.map((g) => ({
+ title: 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
+ onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
+ >
+
0 ? "text-blue-600" : "text-gray-800"}`}>{v}
+ {assignees.length > 0 && (
+
+ {summarizeAssignees(assignees)}
+
+ )}
+
+ );
+ },
+ })),
})),
{
title: "合计",
@@ -182,11 +207,15 @@ export default function MatrixBoard() {
📊 WIP 分布矩阵
-
生产分布透视表:规格型号 × 工序 · 含已完成/已入库
+
生产分布透视表:规格型号 × 工序 · 含售后回流与已完结
{/* 🔴 术语解释:区分"已完成"(车间完工待实收)与"已入库"(仓库已扫码实收) */}
ⓘ "已完成" = 车间完工已转交仓库,等待仓库扫码实收;"已入库" = 仓库已扫码确认实收。
+ {/* 🟣 三区段说明:强调售后区独立计数,不与生产区同名工序混算 */}
+
+ ⓘ 表头分三区:生产区(产线工序)|售后区(出库回流设备的「发货测试 / 售后维修」,独立计数,不并入生产区的测试 / 维修)|完结区。
+
{/* ⏰ 时间筛选 */}
diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx
index a839a46..6ac7eb5 100644
--- a/frontend/src/pages/admin/AdminDashboard.tsx
+++ b/frontend/src/pages/admin/AdminDashboard.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback } from "react";
+import { useEffect, useState, useCallback, useRef } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
@@ -7,9 +7,12 @@ import {
import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
- fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
- type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
+ fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations,
+ type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation,
} from "../../services/dashboardApi";
+import UserOperationDetailDrawer, {
+ type UserOperationDetailDrawerHandle,
+} from "../../components/admin/UserOperationDetailDrawer";
const { RangePicker } = DatePicker;
@@ -276,11 +279,8 @@ export default function AdminDashboard() {
// 抽屉内独立时间筛选(不依赖顶部)
const [opDateKey, setOpDateKey] = useState
("today");
const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
- // 操作明细下钻
- const [opDetailOpen, setOpDetailOpen] = useState(false);
- const [opDetailList, setOpDetailList] = useState([]);
- const [opDetailLoading, setOpDetailLoading] = useState(false);
- const [opDetailTitle, setOpDetailTitle] = useState("");
+ // 操作明细下钻 — 交给共用抽屉组件(与大屏 ScreenDashboard 同一实现)
+ const opDetailRef = useRef(null);
// ── 加载主数据 ──
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => {
@@ -395,14 +395,15 @@ export default function AdminDashboard() {
// 点击数字下钻查看明细(跟随抽屉内时间)
const openOpDetail = (user: UserOperation, actionType: string, label: string) => {
- setOpDetailOpen(true);
- setOpDetailTitle(`${user.user_name} · ${label}`);
- setOpDetailLoading(true);
const { since, until } = rangeToParams(opDateKey, opCustomRange);
- fetchOperationDetail(user.user_id, actionType, since, until)
- .then(setOpDetailList)
- .catch(() => setOpDetailList([]))
- .finally(() => setOpDetailLoading(false));
+ opDetailRef.current?.open({
+ userId: user.user_id,
+ userName: user.user_name,
+ actionType,
+ label,
+ since,
+ until,
+ });
};
const onMsgSearch = (value: string) => {
@@ -859,38 +860,8 @@ export default function AdminDashboard() {
)}
- {/* ═══ 操作明细抽屉 ═══ */}
- 📋 {opDetailTitle} {opDetailList.length} 条}
- open={opDetailOpen}
- onClose={() => setOpDetailOpen(false)}
- size="large"
- styles={{ body: { padding: 16, background: "#f8fafc" } }}
- >
- {opDetailLoading ? (
-
-
-
- ) : opDetailList.length === 0 ? (
- 该时段暂无相关记录
- ) : (
-
- {opDetailList.map((d, i) => (
-
-
- {d.task_name}
- {d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}
-
- {d.remark &&
{d.remark}
}
-
- {d.material_name || "未知设备"}
- {d.product_sn && 身份证: {d.product_sn}}
-
-
- ))}
-
- )}
-
+ {/* ═══ 操作明细抽屉 — 公共组件,与大屏 ScreenDashboard 共用同一实现 ═══ */}
+
);
}
diff --git a/frontend/src/pages/admin/ScreenDashboard.tsx b/frontend/src/pages/admin/ScreenDashboard.tsx
new file mode 100644
index 0000000..aa01909
--- /dev/null
+++ b/frontend/src/pages/admin/ScreenDashboard.tsx
@@ -0,0 +1,482 @@
+/** 管理层数据大屏 — 全屏无侧边栏 · 亮色主题(与系统其他页面统一)· 60s 自动轮询
+ *
+ * 视角:日常运营与督导 —— 当月吞吐 / 当前卡点 / 系统活跃度。
+ *
+ * 布局:
+ * 顶部一排 4 张当月指标卡(生产流转 / 已入库 / 已出库 / 返厂回流)
+ * 左·主图区 【车间工序积压分布】横向柱状图,按生产段/售后段着色
+ * 右·侧边区 【系统活跃度排行】本月 接收 / 转交 / 备注 次数表(主体,撑满高度)
+ *
+ * 跳转:下钻一律用 window.open 开新标签页 —— 领导看板时不丢失大盘视图。
+ * 过滤:URL 参数直接映射到 AdminTasksPage 的原生筛选状态
+ * (?status= 点亮状态 Tabs、?stage= 点亮「当前工序」列筛选),
+ * 不引入任何大屏专用的额外 UI。
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { ReactNode } from "react";
+import { Navigate, useNavigate } from "react-router-dom";
+import { Maximize2, Minimize2, RefreshCw, ArrowLeft } from "lucide-react";
+import type { EChartsCoreOption } from "echarts/core";
+
+import BaseEChart from "../../components/BaseEChart";
+import { useAuth } from "../../contexts/AuthContext";
+import {
+ fetchMonthlyMetrics,
+ fetchWipDistribution,
+ fetchActiveUsers,
+ type MonthlyMetrics,
+ type WipDistributionResponse,
+ type ActiveUser,
+ type ActiveUsersResponse,
+} from "../../services/screenApi";
+import UserOperationDetailDrawer, {
+ type UserOperationDetailDrawerHandle,
+} from "../../components/admin/UserOperationDetailDrawer";
+
+// 大屏轮询间隔
+const REFRESH_MS = 60_000;
+
+// 亮色主题下的 ECharts 配色
+const AXIS_COLOR = "#6b7280"; // 坐标轴文字 gray-500
+const GRID_COLOR = "#e5e7eb"; // 网格线 gray-200
+const TEXT_COLOR = "#374151"; // 标签文字 gray-700
+const TOOLTIP_STYLE = {
+ backgroundColor: "rgba(255,255,255,0.97)",
+ borderColor: "#d1d5db",
+ textStyle: { color: "#1f2937" },
+ extraCssText: "box-shadow:0 4px 12px rgba(0,0,0,0.08);",
+};
+
+// 生产段 / 售后段的柱子配色
+const COLOR_PRODUCTION = "#0891b2"; // cyan-600
+const COLOR_AFTER_SALES = "#ea580c"; // orange-600
+
+// 操作类型 → 中文名(明细抽屉标题用)
+const OP_LABELS: Record
= {
+ receive: "接收",
+ transfer: "转交",
+ record: "上传备注",
+};
+
+// ============================================================
+// 鉴权外壳 — 与 AdminLayout 同款守卫,但不渲染侧边栏
+// ============================================================
+
+export default function ScreenDashboard() {
+ const { isAuthenticated, loading } = useAuth();
+
+ if (loading) {
+ return (
+
+ );
+ }
+ if (!isAuthenticated) return ;
+
+ return ;
+}
+
+// ============================================================
+// 大屏主体
+// ============================================================
+
+function ScreenBoard() {
+ const navigate = useNavigate();
+
+ const [metrics, setMetrics] = useState(null);
+ const [dist, setDist] = useState(null);
+ const [users, setUsers] = useState(null);
+
+ // 操作明细下钻 — 与「全局概览」AdminDashboard 的人员操作统计共用同一抽屉组件
+ const opDetailRef = useRef(null);
+
+ const [updatedAt, setUpdatedAt] = useState("");
+ const [error, setError] = useState("");
+ const [refreshing, setRefreshing] = useState(false);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ // 卸载后不再 setState;reqId 丢弃过期响应(防止慢请求覆盖新数据)
+ const aliveRef = useRef(true);
+ const reqIdRef = useRef(0);
+
+ const load = useCallback(async () => {
+ const reqId = ++reqIdRef.current;
+ setRefreshing(true);
+ try {
+ const [m, d, u] = await Promise.all([
+ fetchMonthlyMetrics(),
+ fetchWipDistribution(),
+ fetchActiveUsers(10), // 多取一些,过滤掉 0 操作的人后再展示
+ ]);
+ if (!aliveRef.current || reqId !== reqIdRef.current) return;
+ setMetrics(m);
+ setDist(d);
+ setUsers(u);
+ setError("");
+ setUpdatedAt(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
+ } catch {
+ if (!aliveRef.current || reqId !== reqIdRef.current) return;
+ setError("数据加载失败,将自动重试");
+ } finally {
+ if (aliveRef.current && reqId === reqIdRef.current) setRefreshing(false);
+ }
+ }, []);
+
+ // 首屏加载 + 60s 轮询
+ useEffect(() => {
+ aliveRef.current = true;
+ load();
+ const timer = setInterval(load, REFRESH_MS);
+ return () => {
+ aliveRef.current = false;
+ clearInterval(timer);
+ };
+ }, [load]);
+
+ // 全屏状态同步(用户按 Esc 退出时图标要跟着变)
+ useEffect(() => {
+ const onChange = () => setIsFullscreen(!!document.fullscreenElement);
+ document.addEventListener("fullscreenchange", onChange);
+ return () => document.removeEventListener("fullscreenchange", onChange);
+ }, []);
+
+ function toggleFullscreen() {
+ if (document.fullscreenElement) void document.exitFullscreen();
+ else void document.documentElement.requestFullscreen();
+ }
+
+ /** 下钻一律开新标签页 —— 领导看板时不丢失大盘视图 */
+ const openDrill = useCallback((url: string) => {
+ window.open(url, "_blank", "noopener,noreferrer");
+ }, []);
+
+ // 柱状图下钻:点击某道工序 → 任务全景页点亮「当前工序」列筛选
+ const handleBarClick = useCallback((params: any) => {
+ const stage = params?.name;
+ if (!stage) return;
+ openDrill(`/admin/tasks?stage=${encodeURIComponent(stage)}`);
+ }, [openDrill]);
+
+ // ── 横向柱状图:车间工序积压分布 ──
+ const distOption = useMemo(() => {
+ const items = dist?.items ?? [];
+ const total = dist?.total ?? 0;
+ return {
+ tooltip: {
+ trigger: "axis",
+ axisPointer: { type: "shadow" },
+ formatter: (params: any) => {
+ const row = items[params[0]?.dataIndex ?? 0];
+ if (!row) return "";
+ const pct = total ? ((row.count / total) * 100).toFixed(1) : "0.0";
+ const phaseLabel = row.phase === "AFTER_SALES" ? "售后段" : "生产段";
+ return `${row.stage}(${phaseLabel})
${row.count} 台 · 占比 ${pct}%`;
+ },
+ ...TOOLTIP_STYLE,
+ },
+ grid: { left: 6, right: 52, top: 8, bottom: 2, containLabel: true },
+ xAxis: {
+ type: "value",
+ minInterval: 1,
+ axisLabel: { color: AXIS_COLOR, fontSize: 11 },
+ splitLine: { lineStyle: { color: GRID_COLOR } },
+ },
+ yAxis: {
+ type: "category",
+ data: items.map((i) => i.stage),
+ inverse: true, // 首项(待启动)排在最上方,保持工序先后顺序
+ axisLine: { lineStyle: { color: "#d1d5db" } },
+ axisTick: { show: false },
+ axisLabel: { color: TEXT_COLOR, fontSize: 13 },
+ },
+ series: [{
+ type: "bar",
+ barMaxWidth: 26,
+ data: items.map((i) => i.count),
+ itemStyle: {
+ // 回调按生命周期阶段区分生产段 / 售后段
+ color: (p: any) => (items[p.dataIndex]?.phase === "AFTER_SALES"
+ ? COLOR_AFTER_SALES
+ : COLOR_PRODUCTION),
+ borderRadius: [0, 4, 4, 0],
+ },
+ label: {
+ show: true,
+ position: "right",
+ color: TEXT_COLOR,
+ fontSize: 12,
+ fontWeight: "bold",
+ },
+ }],
+ };
+ }, [dist]);
+
+ // 仅展示本月确实有操作的人员 —— 0 接收 / 0 转交 / 0 备注 一律隐藏
+ const activeUsers = (users?.items ?? []).filter((u) => u.total > 0);
+ const maxTotal = activeUsers.length ? Math.max(...activeUsers.map((u) => u.total)) : 1;
+
+ // 明细抽屉的时间范围取「本月」,与顶部卡片口径一致
+ const monthSince = metrics
+ ? new Date(`${metrics.month_start}T00:00:00+08:00`).toISOString()
+ : undefined;
+
+ function openUserDetail(u: ActiveUser, actionType: string, label: string) {
+ opDetailRef.current?.open({
+ userId: u.user_id,
+ userName: u.user_name,
+ actionType,
+ label,
+ since: monthSince,
+ });
+ }
+
+ /** 整行点击:默认展开该人首个有记录的操作类型(与行内数字按钮同源) */
+ function openUserRow(u: ActiveUser) {
+ if (u.receive_count > 0) return openUserDetail(u, "receive", "接收");
+ if (u.transfer_count > 0) return openUserDetail(u, "transfer", "转交");
+ return openUserDetail(u, "record", "上传备注");
+ }
+
+ return (
+
+ {/* ── 顶栏 ── */}
+
+
+
+ {/* ── 顶部一排:4 张当月指标卡(点击新标签页下钻任务全景) ── */}
+
+ openDrill("/admin/tasks?status=WIP")}
+ />
+ openDrill("/admin/tasks?status=ARCHIVED")}
+ />
+ openDrill("/admin/tasks?status=OUTBOUND")}
+ />
+ openDrill("/admin/tasks?status=AFTER_SALES_WIP")}
+ />
+
+
+ {/* ── 主区:左积压分布 + 右活跃度 ── */}
+
+ {/* 左·主图区 */}
+
+
+
+
+ 生产段
+
+
+
+ 售后段
+
+
+ 未完结合计 {dist?.total ?? 0} 台
+
+
+ }
+ >
+ 车间工序积压分布
+
+
+
+
+
+
+ {/* 右·侧边区:系统活跃度排行(主体,撑满右侧高度) */}
+
+
+
+ 本月活跃 {activeUsers.length} 人 · 点击查看明细
+
+ }
+ >
+ 系统活跃度排行
+
+
+ {activeUsers.length > 0 ? (
+
+
+
+ | 姓名 |
+ 接收 |
+ 转交 |
+ 备注 |
+ 合计 |
+
+
+
+ {activeUsers.map((u) => (
+ openUserRow(u)}
+ className="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50"
+ >
+
+
+
+ {u.user_name}
+
+ {/* 胶囊条:相对活跃度 */}
+
+
+ |
+ {/* 与「全局概览 · 人员操作统计」一致:点数字看对应类型的明细 */}
+ {([
+ ["receive", u.receive_count],
+ ["transfer", u.transfer_count],
+ ["record", u.record_count],
+ ] as const).map(([type, cnt]) => (
+
+
+ |
+ ))}
+ {u.total} |
+
+ ))}
+
+
+ ) : (
+
+ {users ? "本月暂无操作记录" : "加载中…"}
+
+ )}
+
+
+
+
+
+
+ {/* 操作明细抽屉 — 与「全局概览」共用同一组件(亮色主题,与系统一致) */}
+
+
+ );
+}
+
+// ============================================================
+// 局部小组件
+// ============================================================
+
+function PanelTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) {
+ return (
+