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

@ -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() {