feat: 管理层数据大屏(后端聚合接口 + 前端全屏页面)

PC 管理端新增独立全屏数据大屏,供管理层查看直通率/产量趋势/不良分布,
替代原 AdminDashboard 上零散的手工下钻。

后端:
- endpoints/screen.py + services/screen_service.py: 大屏聚合接口
  (复用 lifecycle 的售后工序归一,保证统计口径与展示一致)
- router.py: 注册 screen_router

前端:
- pages/admin/ScreenDashboard.tsx: 全屏大屏页(自带鉴权守卫,无侧边栏)
- services/screenApi.ts: 大屏数据接口封装
- components/admin/UserOperationDetailDrawer.tsx: 人员操作明细抽屉,
  由 AdminDashboard 的原生 state 抽成独立组件(含命令式 handle)
- AdminDashboard.tsx: 改为使用该抽屉组件,移除内联的下钻状态
- MatrixBoard.tsx / App.tsx / AdminLayout.tsx: 挂载路由与导航入口
- BaseEChart.tsx: 注册 GaugeChart 与 GraphicComponent
  (graphic 需显式注册,否则饼图中心文字静默不渲染)
This commit is contained in:
2026-09-15 10:57:46 +08:00
parent 71e660b428
commit 0d1e45e3fb
11 changed files with 1054 additions and 76 deletions

View File

@ -25,6 +25,7 @@ const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
export default function App() {
return (
@ -47,6 +48,9 @@ export default function App() {
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 管理层数据大屏(独立全屏,无侧边栏,组件内自带鉴权守卫) */}
<Route path="/admin/screen" element={<ScreenDashboard />} />
{/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}>

View File

@ -2,9 +2,9 @@
* 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { LineChart, BarChart, CustomChart } from "echarts/charts";
import { LineChart, BarChart, CustomChart, GaugeChart } from "echarts/charts";
import {
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent,
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GraphicComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { EChartsCoreOption } from "echarts/core";
@ -14,10 +14,12 @@ echarts.use([
LineChart,
BarChart,
CustomChart,
GaugeChart, // 仪表盘(预留,当前大屏未使用)
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
GraphicComponent, // 大屏饼图中心文字(graphic 需显式注册,否则静默不渲染)
CanvasRenderer,
]);

View File

@ -0,0 +1,114 @@
/** 人员操作明细抽屉 — 数据源 /dashboard/user-operations/detail
*
* AdminDashboard(全局概览 · 人员操作统计)与 ScreenDashboard(大屏 · 系统活跃度)
* 共用同一实现,保证两处的下钻交互与呈现完全一致。
*
* 用法:ref.open({ userId, userName, actionType, label, since, until })
*/
import { forwardRef, useImperativeHandle, useState } from "react";
import { Drawer } from "antd";
import dayjs from "dayjs";
import { Loader2 } from "lucide-react";
import { fetchOperationDetail, type OperationDetail } from "../../services/dashboardApi";
export interface OperationDetailRequest {
userId: string;
userName: string;
/** 操作类型: receive / transfer / record */
actionType: string;
/** 类型中文名(接收 / 转交 / 上传备注),用于标题 */
label: string;
/** 时间范围 ISO;缺省则不加时间过滤 */
since?: string;
until?: string;
}
export interface UserOperationDetailDrawerHandle {
open: (req: OperationDetailRequest) => void;
}
const UserOperationDetailDrawer = forwardRef<
UserOperationDetailDrawerHandle,
{ theme?: "light" | "dark" }
>(function UserOperationDetailDrawer({ theme = "light" }, ref) {
const [open, setOpen] = useState(false);
const [list, setList] = useState<OperationDetail[]>([]);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const dark = theme === "dark";
useImperativeHandle(ref, () => ({
open: (req) => {
setOpen(true);
setTitle(`${req.userName} · ${req.label}`);
setLoading(true);
fetchOperationDetail(req.userId, req.actionType, req.since, req.until)
.then(setList)
.catch(() => setList([]))
.finally(() => setLoading(false));
},
}));
return (
<Drawer
title={
<span className={`text-base font-bold ${dark ? "text-slate-100" : ""}`}>
📋 {title}
<span className={`ml-1 font-normal ${dark ? "text-slate-500" : "text-gray-400"}`}>
{list.length} 条
</span>
</span>
}
open={open}
onClose={() => setOpen(false)}
size="large"
styles={{
header: dark ? { background: "#0f172a", borderBottom: "1px solid #1e293b" } : undefined,
content: dark ? { background: "#0b1220" } : undefined,
body: { padding: 16, background: dark ? "#0b1220" : "#f8fafc" },
}}
>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className={`h-6 w-6 animate-spin ${dark ? "text-cyan-500" : "text-blue-500"}`} />
</div>
) : list.length === 0 ? (
<div className={`py-16 text-center text-sm ${dark ? "text-slate-600" : "text-gray-400"}`}>
该时段暂无相关记录
</div>
) : (
<div>
{list.map((d, i) => (
<div
key={i}
className={`mb-2 rounded-lg border px-4 py-3 shadow-sm ${
dark ? "border-slate-800 bg-slate-900/50" : "border-gray-100 bg-white"
}`}
>
<div className="flex items-center justify-between">
<span className={`text-sm font-semibold ${dark ? "text-slate-100" : "text-gray-800"}`}>
{d.task_name}
</span>
<span className={`shrink-0 text-xs ${dark ? "text-slate-500" : "text-gray-400"}`}>
{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}
</span>
</div>
{d.remark && (
<p className={`mt-1 text-xs leading-relaxed ${dark ? "text-slate-300" : "text-gray-600"}`}>
{d.remark}
</p>
)}
<div className={`mt-1.5 text-[11px] ${dark ? "text-slate-500" : "text-gray-400"}`}>
<span>{d.material_name || "未知设备"}</span>
{d.product_sn && <span className="ml-2 font-mono">身份证: {d.product_sn}</span>}
</div>
</div>
))}
</div>
)}
</Drawer>
);
});
export default UserOperationDetailDrawer;

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2 } from "lucide-react";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
{
title: "管理层大屏",
path: "/admin/screen",
icon: Tv,
description: "全屏数据大盘 · 直通率 / 产量趋势 / 不良分布",
},
];
export default function AdminLayout() {

View File

@ -12,8 +12,18 @@ import { lifecycleBadge } from "../constants/task";
const { RangePicker } = DatePicker;
// 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减
const FIXED_STEP_COLUMNS = ["待接收", "备货", "生产", "测试", "维修", "已完成", "已入库", "已出库"];
// 🔧 三区段列定义:生产区 / 售后区 / 完结区
// 售后区必须独立成列 —— 出库回流设备的「发货测试 / 售后维修」若与生产区的
// 「测试 / 维修」共用一列,两类设备会被加在一起,统计完全失真。
// accent 用于给分组表头着色,售后区用紫色呼应标签配色(红=驳回语义,不占用)。
const COLUMN_GROUPS: { title: string; keys: string[]; accent: string }[] = [
{ title: "生产区", keys: ["待接收", "备货", "生产", "测试", "维修"], accent: "text-blue-700" },
{ title: "售后区", keys: ["发货测试", "售后维修"], accent: "text-purple-700" },
{ title: "完结区", keys: ["已完成", "已入库", "已出库"], accent: "text-gray-700" },
];
// 固定标准主轴:三区段的全部列始终显示(即使计数为 0),表头不随操作内容增减
const FIXED_STEP_COLUMNS = COLUMN_GROUPS.flatMap((g) => g.keys);
// ⏰ 时间筛选(与全局概览一致)
type DateRangeKey = "today" | "7d" | "30d" | "custom";
@ -112,7 +122,18 @@ export default function MatrixBoard() {
// 🔧 全局唯一表头 + 单表数据:固定标准主轴在前,动态工序(数据中出现的新工序)追加在后
const { columns, dataSource, keys } = useMemo(() => {
const dynamicKeys = Array.from(new Set(data.map((d) => d.dimension_key)));
const keys = Array.from(new Set([...FIXED_STEP_COLUMNS, ...dynamicKeys]));
const fixedKeys = new Set(FIXED_STEP_COLUMNS);
const extraKeys = dynamicKeys.filter((k) => !fixedKeys.has(k));
// 🔧 三区段分组:自定义工序(喷漆 / 老化…)挂到生产区末尾 —— 它们是产线自建工序,
// 与售后区那两个受控工序名不同,无法从名字反推归属,故按生产口径展示。
// 注:售后设备的历史工序名已在后端归一(测试→发货测试 / 维修→售后维修),
// 所以这里拿到的 dimension_key 已经是正确的区段归属。
const groups = COLUMN_GROUPS.map((g, i) => ({
...g,
keys: i === 0 ? [...g.keys, ...extraKeys] : g.keys,
}));
const keys = groups.flatMap((g) => g.keys);
const rowsMap = new Map<string, any>();
for (const item of data) {
@ -141,28 +162,32 @@ export default function MatrixBoard() {
</div>
),
},
...keys.map((key) => ({
title: key,
dataIndex: key,
align: "center" as const,
width: 96,
render: (val: number, record: any) => {
const v = val || 0;
const assignees = record._assignees?.[key] || [];
return (
<div
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
>
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
{assignees.length > 0 && (
<div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500">
{summarizeAssignees(assignees)}
</div>
)}
</div>
);
},
// 🔧 两行表头:第一行区段名(生产区 / 售后区 / 完结区),第二行具体工序
...groups.map((g) => ({
title: <span className={`text-xs font-bold ${g.accent}`}>{g.title}</span>,
children: g.keys.map((key) => ({
title: key,
dataIndex: key,
align: "center" as const,
width: 96,
render: (val: number, record: any) => {
const v = val || 0;
const assignees = record._assignees?.[key] || [];
return (
<div
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
>
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
{assignees.length > 0 && (
<div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500">
{summarizeAssignees(assignees)}
</div>
)}
</div>
);
},
})),
})),
{
title: "合计",
@ -182,11 +207,15 @@ export default function MatrixBoard() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 WIP 分布矩阵</h2>
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含已完成/已入库</p>
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含售后回流与已完结</p>
{/* 🔴 术语解释:区分"已完成"(车间完工待实收)与"已入库"(仓库已扫码实收) */}
<p className="mt-1 text-xs text-red-500">
ⓘ "已完成" = 车间完工已转交仓库,等待仓库扫码实收;"已入库" = 仓库已扫码确认实收。
</p>
{/* 🟣 三区段说明:强调售后区独立计数,不与生产区同名工序混算 */}
<p className="mt-1 text-xs text-purple-600">
ⓘ 表头分三区:<b>生产区</b>(产线工序)|<b>售后区</b>(出库回流设备的「发货测试 / 售后维修」,独立计数,不并入生产区的测试 / 维修)|<b>完结区</b>。
</p>
</div>
{/* ⏰ 时间筛选 */}
<div className="flex flex-wrap items-center gap-2">

View File

@ -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<DateRangeKey>("today");
const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
// 操作明细下钻
const [opDetailOpen, setOpDetailOpen] = useState(false);
const [opDetailList, setOpDetailList] = useState<OperationDetail[]>([]);
const [opDetailLoading, setOpDetailLoading] = useState(false);
const [opDetailTitle, setOpDetailTitle] = useState("");
// 操作明细下钻 — 交给共用抽屉组件(与大屏 ScreenDashboard 同一实现)
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(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() {
)}
</Drawer>
{/* ═══ 操作明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">📋 {opDetailTitle} <span className="font-normal text-gray-400">{opDetailList.length} 条</span></span>}
open={opDetailOpen}
onClose={() => setOpDetailOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{opDetailLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : opDetailList.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">该时段暂无相关记录</div>
) : (
<div>
{opDetailList.map((d, i) => (
<div key={i} className="mb-2 rounded-lg border border-gray-100 bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{d.task_name}</span>
<span className="shrink-0 text-xs text-gray-400">{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}</span>
</div>
{d.remark && <p className="mt-1 text-xs leading-relaxed text-gray-600">{d.remark}</p>}
<div className="mt-1.5 text-[11px] text-gray-400">
<span>{d.material_name || "未知设备"}</span>
{d.product_sn && <span className="ml-2 font-mono">身份证: {d.product_sn}</span>}
</div>
</div>
))}
</div>
)}
</Drawer>
{/* ═══ 操作明细抽屉 — 公共组件,与大屏 ScreenDashboard 共用同一实现 ═══ */}
<UserOperationDetailDrawer ref={opDetailRef} />
</div>
);
}

View File

@ -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<string, string> = {
receive: "接收",
transfer: "转交",
record: "上传备注",
};
// ============================================================
// 鉴权外壳 — 与 AdminLayout 同款守卫,但不渲染侧边栏
// ============================================================
export default function ScreenDashboard() {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-50">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
);
}
if (!isAuthenticated) return <Navigate to="/admin/login" replace />;
return <ScreenBoard />;
}
// ============================================================
// 大屏主体
// ============================================================
function ScreenBoard() {
const navigate = useNavigate();
const [metrics, setMetrics] = useState<MonthlyMetrics | null>(null);
const [dist, setDist] = useState<WipDistributionResponse | null>(null);
const [users, setUsers] = useState<ActiveUsersResponse | null>(null);
// 操作明细下钻 — 与「全局概览」AdminDashboard 的人员操作统计共用同一抽屉组件
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(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<EChartsCoreOption>(() => {
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})<br/><b>${row.count}</b> 台 · 占比 ${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 (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-gray-50 text-gray-800">
{/* ── 顶栏 ── */}
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-2.5">
<div className="flex items-center gap-3">
<span className="h-5 w-1.5 rounded-full bg-blue-600" />
<h1 className="text-lg font-bold tracking-wide text-gray-800">
生产流转 · 管理层数据大屏
</h1>
{metrics?.month && (
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
{metrics.month} 当月
</span>
)}
{error && (
<span className="rounded bg-red-50 px-2 py-0.5 text-xs text-red-600">{error}</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="tabular-nums">最后更新 {updatedAt || "—"}</span>
<button
onClick={load}
disabled={refreshing}
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
刷新
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
>
{isFullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
{isFullscreen ? "退出全屏" : "全屏"}
</button>
<button
onClick={() => navigate("/admin/dashboard")}
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
>
<ArrowLeft className="h-3.5 w-3.5" />
返回后台
</button>
</div>
</header>
<main className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{/* ── 顶部一排:4 张当月指标卡(点击新标签页下钻任务全景) ── */}
<div className="grid shrink-0 grid-cols-4 gap-4">
<MetricCard
label="本月生产流转"
value={metrics?.month_production ?? 0}
tone="text-cyan-600"
hint="本月有流转或新建的生产态设备"
onClick={() => openDrill("/admin/tasks?status=WIP")}
/>
<MetricCard
label="本月已入库"
value={metrics?.month_inbound ?? 0}
tone="text-sky-600"
hint="扫码入库完成"
/* 直接点亮任务页原生的「已入库」Tab */
onClick={() => openDrill("/admin/tasks?status=ARCHIVED")}
/>
<MetricCard
label="本月已出库"
value={metrics?.month_outbound ?? 0}
tone="text-green-600"
hint="扫码出库发货"
onClick={() => openDrill("/admin/tasks?status=OUTBOUND")}
/>
<MetricCard
label="本月返厂回流"
value={metrics?.month_returned ?? 0}
tone="text-orange-600"
hint="本月转入售后阶段"
/* 直接点亮原生的「售后流转中」Tab */
onClick={() => openDrill("/admin/tasks?status=AFTER_SALES_WIP")}
/>
</div>
{/* ── 主区:左积压分布 + 右活跃度 ── */}
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4">
{/* 左·主图区 */}
<section className="col-span-7 flex min-h-0 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<PanelTitle
extra={
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5 text-gray-500">
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_PRODUCTION }} />
生产段
</span>
<span className="flex items-center gap-1.5 text-gray-500">
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_AFTER_SALES }} />
售后段
</span>
<span className="text-gray-500">
未完结合计 <b className="text-cyan-600">{dist?.total ?? 0}</b> 台
</span>
</div>
}
>
车间工序积压分布
</PanelTitle>
<div className="min-h-0 flex-1">
<BaseEChart
option={distOption}
height="100%"
onEvents={{ click: handleBarClick }}
/>
</div>
</section>
{/* 右·侧边区:系统活跃度排行(主体,撑满右侧高度) */}
<div className="col-span-5 flex min-h-0 flex-col">
<section className="flex min-h-0 flex-1 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<PanelTitle
extra={
<span className="text-xs text-gray-400">
本月活跃 {activeUsers.length} 人 · 点击查看明细
</span>
}
>
系统活跃度排行
</PanelTitle>
<div className="min-h-0 flex-1 overflow-hidden">
{activeUsers.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400">
<th className="pb-1.5 text-left font-normal">姓名</th>
<th className="w-10 pb-1.5 text-right font-normal">接收</th>
<th className="w-10 pb-1.5 text-right font-normal">转交</th>
<th className="w-10 pb-1.5 text-right font-normal">备注</th>
<th className="w-12 pb-1.5 text-right font-normal">合计</th>
</tr>
</thead>
<tbody>
{activeUsers.map((u) => (
<tr
key={u.user_id}
title="点击查看该人员的操作明细"
onClick={() => openUserRow(u)}
className="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50"
>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 truncate text-gray-700">
{u.user_name}
</span>
{/* 胶囊条:相对活跃度 */}
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-100">
<div
className="h-1 rounded-full bg-blue-500"
style={{ width: `${Math.max((u.total / maxTotal) * 100, 6)}%` }}
/>
</div>
</div>
</td>
{/* 与「全局概览 · 人员操作统计」一致:点数字看对应类型的明细 */}
{([
["receive", u.receive_count],
["transfer", u.transfer_count],
["record", u.record_count],
] as const).map(([type, cnt]) => (
<td key={type} className="py-1.5 text-right">
<button
onClick={(e) => {
e.stopPropagation(); // 不冒泡触发整行的默认下钻
openUserDetail(u, type, OP_LABELS[type]);
}}
className="cursor-pointer tabular-nums text-gray-500 transition-colors hover:text-blue-600 hover:underline"
>
{cnt}
</button>
</td>
))}
<td className="py-1.5 text-right font-bold tabular-nums text-blue-600">{u.total}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
{users ? "本月暂无操作记录" : "加载中…"}
</div>
)}
</div>
</section>
</div>
</div>
</main>
{/* 操作明细抽屉 — 与「全局概览」共用同一组件(亮色主题,与系统一致) */}
<UserOperationDetailDrawer ref={opDetailRef} />
</div>
);
}
// ============================================================
// 局部小组件
// ============================================================
function PanelTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) {
return (
<div className="mb-2 flex shrink-0 items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-gray-700">
<span className="h-3.5 w-1 rounded-full bg-blue-600" />
{children}
</h2>
{extra}
</div>
);
}
function MetricCard({
label, value, tone, hint, onClick,
}: {
label: string;
value: number;
tone: string;
hint: string;
onClick?: () => void;
}) {
return (
<div
onClick={onClick}
role={onClick ? "button" : undefined}
className={`rounded-xl border border-gray-200 bg-white px-5 py-3 shadow-sm transition-colors ${
onClick ? "cursor-pointer hover:border-blue-400 hover:bg-blue-50/40" : ""
}`}
>
<p className="text-xs text-gray-500">{label}</p>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className={`text-4xl font-bold tabular-nums ${tone}`}>{value}</span>
<span className="text-xs text-gray-400">台</span>
</div>
<p className="mt-0.5 text-[11px] text-gray-400">{hint}</p>
{onClick && <p className="mt-1 text-[10px] text-blue-500">点击下钻(新标签页)→</p>}
</div>
);
}

View File

@ -0,0 +1,62 @@
/** 管理层数据大屏 — 前后端数据契约类型 + API
* 对应后端 app/services/screen_service.py 的 Pydantic Schema
*/
import api from "./api";
// ─── 当月吞吐(顶部数字卡) ────────────────────────────────
export interface MonthlyMetrics {
month: string; // "2026-09"
month_start: string; // "2026-09-01"(北京时间)
month_production: number; // 本月有流转记录或新建的生产态设备数
month_inbound: number; // 本月扫码入库数
month_outbound: number; // 本月扫码出库数
month_returned: number; // 本月进入售后/回流状态的设备数
}
// ─── 工序积压分布(柱状图) ────────────────────────────────
export interface WipStage {
stage: string;
phase: "PRODUCTION" | "AFTER_SALES";
count: number;
}
export interface WipDistributionResponse {
total: number;
items: WipStage[];
}
// ─── 系统使用活跃度 ────────────────────────────────────────
export interface ActiveUser {
user_id: string;
user_name: string;
receive_count: number;
transfer_count: number;
record_count: number;
total: number;
}
export interface ActiveUsersResponse {
month: string;
items: ActiveUser[];
}
// ============================================================
// API 方法
// ============================================================
export async function fetchMonthlyMetrics(): Promise<MonthlyMetrics> {
const { data } = await api.get<MonthlyMetrics>("/screen/monthly-metrics");
return data;
}
export async function fetchWipDistribution(): Promise<WipDistributionResponse> {
const { data } = await api.get<WipDistributionResponse>("/screen/wip-distribution");
return data;
}
export async function fetchActiveUsers(topN = 5): Promise<ActiveUsersResponse> {
const { data } = await api.get<ActiveUsersResponse>("/screen/active-users", {
params: { top_n: topN },
});
return data;
}