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

@ -0,0 +1,52 @@
"""大屏 API — 面向管理层**日常运营与督导**的轻量聚合接口
视角:当月吞吐 / 当前卡点 / 系统活跃度。
与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表),
/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.screen_service import (
get_monthly_metrics, MonthlyMetrics,
get_wip_distribution, WipDistributionResponse,
get_active_users, ActiveUsersResponse,
)
router = APIRouter(prefix="/screen", tags=["大屏统计"])
@router.get("/monthly-metrics", response_model=MonthlyMetrics)
async def monthly_metrics(db: AsyncSession = Depends(get_db)):
"""
当月吞吐 — 大屏顶部四张数字卡。
返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。
统计区间为北京时间当月 1 日 00:00 至此刻。
"""
return await get_monthly_metrics(db)
@router.get("/wip-distribution", response_model=WipDistributionResponse)
async def wip_distribution(db: AsyncSession = Depends(get_db)):
"""
工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。
返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。
"""
return await get_wip_distribution(db)
@router.get("/active-users", response_model=ActiveUsersResponse)
async def active_users(
top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"),
db: AsyncSession = Depends(get_db),
):
"""
本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。
桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。
"""
return await get_active_users(db, top_n=top_n)

View File

@ -16,6 +16,7 @@ from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.holidays import router as holidays_router
from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router
api_router = APIRouter()
@ -35,3 +36,4 @@ api_router.include_router(analytics_router)
api_router.include_router(holidays_router)
api_router.include_router(webhooks_router)
api_router.include_router(external_products_router)
api_router.include_router(screen_router)

View File

@ -0,0 +1,254 @@
"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
设计约定:
- 只读,无写操作,字段扁平,便于大屏高频轮询。
- 时间一律按**北京时间**判定DB 列为 timestamptz实存 UTC
比较前统一把边界换算成 UTC避免月初/凌晨的边界漂移。
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
视角说明:
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
"""
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.lifecycle import (
AFTER_SALES_ONLY_STEPS,
LIFECYCLE_AFTER_SALES,
LIFECYCLE_PRODUCTION,
)
from app.core.time_utils import get_beijing_time
from app.models.product import Product
from app.models.task import Task
from app.models.task_log import TaskLog
# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径
FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
# 仓储动作日志类型(由 webhooks / product_finalize_service 写入)
LOG_WAREHOUSE_INBOUND = "warehouse_inbound"
LOG_WAREHOUSE_OUTBOUND = "warehouse_outbound"
def _not_finished():
"""未完结条件 —— overall_status 为 NULL 或不在已完结集合内。
注意 PostgreSQL 中 `NULL NOT IN (...)` 结果为 NULL 而非 TRUE
必须显式带上 IS NULL 分支,否则建单后未流转的设备会被整批漏掉。
"""
return or_(
Product.overall_status.is_(None),
~Product.overall_status.in_(FINISHED_OVERALL),
)
def _month_bounds() -> tuple[datetime, datetime, str]:
"""返回 (本月起点 UTC, 当前时刻 UTC, 'YYYY-MM')。
本月起点 = 北京时间当月 1 日 00:00 —— 直接换算成 UTC 参与 timestamptz 比较,
不依赖数据库会话时区设置。
"""
now_bj = get_beijing_time()
month_start_bj = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return (
month_start_bj.astimezone(timezone.utc),
now_bj.astimezone(timezone.utc),
month_start_bj.strftime("%Y-%m"),
)
# ============================================================
# 1. 当月吞吐指标
# ============================================================
class MonthlyMetrics(BaseModel):
"""大屏顶部四张数字卡(当月视角)"""
month: str # "2026-09"
month_start: str # "2026-09-01"(北京时间)
month_production: int # 本月有流转记录或新建的生产态设备数
month_inbound: int # 本月扫码入库数
month_outbound: int # 本月扫码出库数
month_returned: int # 本月进入售后/回流状态的设备数
async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
"""当月吞吐四联指标。
口径:
- month_productionlifecycle_phase = PRODUCTION且「本月新建」或
「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。
反映这个月实际被推着走的机器有多少。
- month_inbound / month_outboundtask_logs 中 action_type =
warehouse_inbound / warehouse_outbound 的记录数MOM 扫码回调写入)。
- month_returned本月创建过售后专属工序任务发货测试 / 售后维修)的
设备数去重。Product 表无 updated_at无法直接查「转为 AFTER_SALES
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
"""
month_start_utc, now_utc, month_label = _month_bounds()
# 本月该设备产生过任意流转日志
has_activity = (
select(TaskLog.id)
.join(Task, Task.id == TaskLog.task_id)
.where(
Task.product_id == Product.id,
TaskLog.created_at >= month_start_utc,
)
.exists()
)
month_production = await db.scalar(
select(func.count(func.distinct(Product.id))).where(
Product.lifecycle_phase == LIFECYCLE_PRODUCTION,
or_(Product.created_at >= month_start_utc, has_activity),
)
) or 0
async def _count_log(action_type: str) -> int:
return await db.scalar(
select(func.count(TaskLog.id)).where(
TaskLog.action_type == action_type,
TaskLog.created_at >= month_start_utc,
)
) or 0
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
month_returned = await db.scalar(
select(func.count(func.distinct(Task.product_id)))
.join(Product, Task.product_id == Product.id)
.where(
Product.lifecycle_phase == LIFECYCLE_AFTER_SALES,
Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)),
Task.created_at >= month_start_utc,
)
) or 0
return MonthlyMetrics(
month=month_label,
month_start=f"{month_label}-01",
month_production=int(month_production),
month_inbound=int(month_inbound),
month_outbound=int(month_outbound),
month_returned=int(month_returned),
)
# ============================================================
# 2. 工序积压分布(柱状图)
# ============================================================
class WipStage(BaseModel):
stage: str # 工序名(中文,直接作为图表类目)
phase: str # PRODUCTION / AFTER_SALES供前端分区着色
count: int
class WipDistributionResponse(BaseModel):
total: int # 未完结设备总数
items: list[WipStage]
# 阶段与流转顺序 —— 顺序即「工序先后」,前端据此排布柱子
WIP_STAGES: tuple[tuple[str, str], ...] = (
("待启动", LIFECYCLE_PRODUCTION), # overall_status 为空:建单后尚未流转
("备货", LIFECYCLE_PRODUCTION),
("生产", LIFECYCLE_PRODUCTION),
("测试", LIFECYCLE_PRODUCTION),
("维修", LIFECYCLE_PRODUCTION),
("发货测试", LIFECYCLE_AFTER_SALES),
("售后维修", LIFECYCLE_AFTER_SALES),
)
# 「待启动」对应的真实分组键overall_status IS NULL / 空串)
_UNSTARTED_KEY = ""
async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
"""工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。
返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
count 之和恒等于 total避免统计悄悄漏数
"""
rows = await db.execute(
select(Product.overall_status, func.count(Product.id))
.where(_not_finished())
.group_by(Product.overall_status)
)
counts: dict[str, int] = {}
for raw_status, cnt in rows.all():
key = (raw_status or "").strip()
counts[key] = counts.get(key, 0) + cnt
total = sum(counts.values())
items: list[WipStage] = []
for stage, phase in WIP_STAGES:
key = _UNSTARTED_KEY if stage == "待启动" else stage
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in WIP_STAGES}
for key, cnt in counts.items():
if key not in known_keys and cnt:
items.append(WipStage(
stage=key or "待启动",
phase=LIFECYCLE_PRODUCTION,
count=cnt,
))
return WipDistributionResponse(total=total, items=items)
# ============================================================
# 3. 系统使用活跃度(本月人员排行)
# ============================================================
class ActiveUser(BaseModel):
user_id: str
user_name: str
receive_count: int # 接收
transfer_count: int # 转交
record_count: int # 上传备注
total: int
class ActiveUsersResponse(BaseModel):
month: str
items: list[ActiveUser]
async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse:
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
不重复实现聚合逻辑:转交/接收取 task_logsaction_type = receive / complete
上传备注取 task_records排除系统自动备注。仅返回本月**确实有操作**的人员,
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"
"""
from app.services.dashboard_service import get_user_operations
month_start_utc, _now, month_label = _month_bounds()
operations = await get_user_operations(db, since=month_start_utc, until=None)
active = [op for op in operations if op.total > 0][:top_n]
return ActiveUsersResponse(
month=month_label,
items=[
ActiveUser(
user_id=op.user_id,
user_name=op.user_name,
receive_count=op.receive_count,
transfer_count=op.transfer_count,
record_count=op.record_count,
total=op.total,
)
for op in active
],
)

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常规事件与 onZrClickZRender 底层点击,扩大热区)。 */
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,7 +162,10 @@ export default function MatrixBoard() {
</div>
),
},
...keys.map((key) => ({
// 🔧 两行表头:第一行区段名(生产区 / 售后区 / 完结区),第二行具体工序
...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,
@ -164,6 +188,7 @@ export default function MatrixBoard() {
);
},
})),
})),
{
title: "合计",
dataIndex: "row_total",
@ -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);
// 卸载后不再 setStatereqId 丢弃过期响应(防止慢请求覆盖新数据)
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;
}