diff --git a/backend/app/api/v1/endpoints/screen.py b/backend/app/api/v1/endpoints/screen.py new file mode 100644 index 0000000..7455682 --- /dev/null +++ b/backend/app/api/v1/endpoints/screen.py @@ -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) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index e40ff7b..da9efc9 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/services/screen_service.py b/backend/app/services/screen_service.py new file mode 100644 index 0000000..9dd8e98 --- /dev/null +++ b/backend/app/services/screen_service.py @@ -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_production:lifecycle_phase = PRODUCTION,且「本月新建」或 + 「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。 + 反映这个月实际被推着走的机器有多少。 + - month_inbound / month_outbound:task_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_logs(action_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 + ], + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7076c49..42ee41c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 管理端 — 登录页(独立,无侧边栏) */} } /> + {/* PC 管理端 — 管理层数据大屏(独立全屏,无侧边栏,组件内自带鉴权守卫) */} + } /> + {/* PC 管理端 — 需要登录 */} } /> }> diff --git a/frontend/src/components/BaseEChart.tsx b/frontend/src/components/BaseEChart.tsx index a3dbc9e..d66b705 100644 --- a/frontend/src/components/BaseEChart.tsx +++ b/frontend/src/components/BaseEChart.tsx @@ -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, ]); diff --git a/frontend/src/components/admin/UserOperationDetailDrawer.tsx b/frontend/src/components/admin/UserOperationDetailDrawer.tsx new file mode 100644 index 0000000..fba22c2 --- /dev/null +++ b/frontend/src/components/admin/UserOperationDetailDrawer.tsx @@ -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([]); + 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 ( + + 📋 {title} + + {list.length} 条 + + + } + 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 ? ( +
+ +
+ ) : list.length === 0 ? ( +
+ 该时段暂无相关记录 +
+ ) : ( +
+ {list.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}} +
+
+ ))} +
+ )} +
+ ); +}); + +export default UserOperationDetailDrawer; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 00416dd..a8595e5 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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() { diff --git a/frontend/src/pages/MatrixBoard.tsx b/frontend/src/pages/MatrixBoard.tsx index 0ec8454..b5e9b44 100644 --- a/frontend/src/pages/MatrixBoard.tsx +++ b/frontend/src/pages/MatrixBoard.tsx @@ -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(); for (const item of data) { @@ -141,28 +162,32 @@ export default function MatrixBoard() { ), }, - ...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 ( -
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: {g.title}, + 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 ( +
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 ( +
+ {/* ── 顶栏 ── */} +
+
+ +

+ 生产流转 · 管理层数据大屏 +

+ {metrics?.month && ( + + {metrics.month} 当月 + + )} + {error && ( + {error} + )} +
+ +
+ 最后更新 {updatedAt || "—"} + + + +
+
+ +
+ {/* ── 顶部一排: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" + > + + {/* 与「全局概览 · 人员操作统计」一致:点数字看对应类型的明细 */} + {([ + ["receive", u.receive_count], + ["transfer", u.transfer_count], + ["record", u.record_count], + ] as const).map(([type, cnt]) => ( + + ))} + + + ))} + +
姓名接收转交备注合计
+
+ + {u.user_name} + + {/* 胶囊条:相对活跃度 */} +
+
+
+
+
+ + {u.total}
+ ) : ( +
+ {users ? "本月暂无操作记录" : "加载中…"} +
+ )} +
+
+
+
+ + + {/* 操作明细抽屉 — 与「全局概览」共用同一组件(亮色主题,与系统一致) */} + +
+ ); +} + +// ============================================================ +// 局部小组件 +// ============================================================ + +function PanelTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) { + return ( +
+

+ + {children} +

+ {extra} +
+ ); +} + +function MetricCard({ + label, value, tone, hint, onClick, +}: { + label: string; + value: number; + tone: string; + hint: string; + onClick?: () => void; +}) { + return ( +
+

{label}

+
+ {value} + +
+

{hint}

+ {onClick &&

点击下钻(新标签页)→

} +
+ ); +} diff --git a/frontend/src/services/screenApi.ts b/frontend/src/services/screenApi.ts new file mode 100644 index 0000000..641b235 --- /dev/null +++ b/frontend/src/services/screenApi.ts @@ -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 { + const { data } = await api.get("/screen/monthly-metrics"); + return data; +} + +export async function fetchWipDistribution(): Promise { + const { data } = await api.get("/screen/wip-distribution"); + return data; +} + +export async function fetchActiveUsers(topN = 5): Promise { + const { data } = await api.get("/screen/active-users", { + params: { top_n: topN }, + }); + return data; +}