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.holidays import router as holidays_router
from app.api.v1.endpoints.webhooks import router as webhooks_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.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router
api_router = APIRouter() api_router = APIRouter()
@ -35,3 +36,4 @@ api_router.include_router(analytics_router)
api_router.include_router(holidays_router) api_router.include_router(holidays_router)
api_router.include_router(webhooks_router) api_router.include_router(webhooks_router)
api_router.include_router(external_products_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 AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard")); const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard")); const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
export default function App() { export default function App() {
return ( return (
@ -47,6 +48,9 @@ export default function App() {
{/* PC 管理端 — 登录页(独立,无侧边栏) */} {/* PC 管理端 — 登录页(独立,无侧边栏) */}
<Route path="/admin/login" element={<AdminLoginPage />} /> <Route path="/admin/login" element={<AdminLoginPage />} />
{/* PC 管理端 — 管理层数据大屏(独立全屏,无侧边栏,组件内自带鉴权守卫) */}
<Route path="/admin/screen" element={<ScreenDashboard />} />
{/* PC 管理端 — 需要登录 */} {/* PC 管理端 — 需要登录 */}
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} /> <Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<AdminLayout />}> <Route element={<AdminLayout />}>

View File

@ -2,9 +2,9 @@
* 支持 onEvents常规事件与 onZrClickZRender 底层点击,扩大热区)。 */ * 支持 onEvents常规事件与 onZrClickZRender 底层点击,扩大热区)。 */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import * as echarts from "echarts/core"; import * as echarts from "echarts/core";
import { LineChart, BarChart, CustomChart } from "echarts/charts"; import { LineChart, BarChart, CustomChart, GaugeChart } from "echarts/charts";
import { import {
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GraphicComponent,
} from "echarts/components"; } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers"; import { CanvasRenderer } from "echarts/renderers";
import type { EChartsCoreOption } from "echarts/core"; import type { EChartsCoreOption } from "echarts/core";
@ -14,10 +14,12 @@ echarts.use([
LineChart, LineChart,
BarChart, BarChart,
CustomChart, CustomChart,
GaugeChart, // 仪表盘(预留,当前大屏未使用)
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
LegendComponent, LegendComponent,
DataZoomComponent, DataZoomComponent,
GraphicComponent, // 大屏饼图中心文字graphic 需显式注册,否则静默不渲染)
CanvasRenderer, 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 { 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"; import { useAuth } from "../../contexts/AuthContext";
const MENU = [ const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2, icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表", description: "规格型号 × 人员/工序 在制品透视表",
}, },
{
title: "管理层大屏",
path: "/admin/screen",
icon: Tv,
description: "全屏数据大盘 · 直通率 / 产量趋势 / 不良分布",
},
]; ];
export default function AdminLayout() { export default function AdminLayout() {

View File

@ -12,8 +12,18 @@ import { lifecycleBadge } from "../constants/task";
const { RangePicker } = DatePicker; 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"; type DateRangeKey = "today" | "7d" | "30d" | "custom";
@ -112,7 +122,18 @@ export default function MatrixBoard() {
// 🔧 全局唯一表头 + 单表数据:固定标准主轴在前,动态工序(数据中出现的新工序)追加在后 // 🔧 全局唯一表头 + 单表数据:固定标准主轴在前,动态工序(数据中出现的新工序)追加在后
const { columns, dataSource, keys } = useMemo(() => { const { columns, dataSource, keys } = useMemo(() => {
const dynamicKeys = Array.from(new Set(data.map((d) => d.dimension_key))); 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>(); const rowsMap = new Map<string, any>();
for (const item of data) { for (const item of data) {
@ -141,28 +162,32 @@ export default function MatrixBoard() {
</div> </div>
), ),
}, },
...keys.map((key) => ({ // 🔧 两行表头:第一行区段名(生产区 / 售后区 / 完结区),第二行具体工序
title: key, ...groups.map((g) => ({
dataIndex: key, title: <span className={`text-xs font-bold ${g.accent}`}>{g.title}</span>,
align: "center" as const, children: g.keys.map((key) => ({
width: 96, title: key,
render: (val: number, record: any) => { dataIndex: key,
const v = val || 0; align: "center" as const,
const assignees = record._assignees?.[key] || []; width: 96,
return ( render: (val: number, record: any) => {
<div const v = val || 0;
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`} const assignees = record._assignees?.[key] || [];
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)} return (
> <div
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div> className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
{assignees.length > 0 && ( onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
<div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500"> >
{summarizeAssignees(assignees)} <div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
</div> {assignees.length > 0 && (
)} <div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500">
</div> {summarizeAssignees(assignees)}
); </div>
}, )}
</div>
);
},
})),
})), })),
{ {
title: "合计", title: "合计",
@ -182,11 +207,15 @@ export default function MatrixBoard() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="text-xl font-bold text-gray-800">📊 WIP </h2> <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 className="mt-1 text-xs text-red-500">
"已完成" = "已入库" = "已完成" = "已入库" =
</p> </p>
{/* 🟣 三区段说明:强调售后区独立计数,不与生产区同名工序混算 */}
<p className="mt-1 text-xs text-purple-600">
<b></b>线<b></b> / / <b></b>
</p>
</div> </div>
{/* ⏰ 时间筛选 */} {/* ⏰ 时间筛选 */}
<div className="flex flex-wrap items-center gap-2"> <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 { import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle, Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown, RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
@ -7,9 +7,12 @@ import {
import { Radio, DatePicker, Drawer, Input } from "antd"; import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import { import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail, fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail, type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation,
} from "../../services/dashboardApi"; } from "../../services/dashboardApi";
import UserOperationDetailDrawer, {
type UserOperationDetailDrawerHandle,
} from "../../components/admin/UserOperationDetailDrawer";
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@ -276,11 +279,8 @@ export default function AdminDashboard() {
// 抽屉内独立时间筛选(不依赖顶部) // 抽屉内独立时间筛选(不依赖顶部)
const [opDateKey, setOpDateKey] = useState<DateRangeKey>("today"); const [opDateKey, setOpDateKey] = useState<DateRangeKey>("today");
const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null); const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
// 操作明细下钻 // 操作明细下钻 — 交给共用抽屉组件(与大屏 ScreenDashboard 同一实现)
const [opDetailOpen, setOpDetailOpen] = useState(false); const opDetailRef = useRef<UserOperationDetailDrawerHandle>(null);
const [opDetailList, setOpDetailList] = useState<OperationDetail[]>([]);
const [opDetailLoading, setOpDetailLoading] = useState(false);
const [opDetailTitle, setOpDetailTitle] = useState("");
// ── 加载主数据 ── // ── 加载主数据 ──
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | 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) => { const openOpDetail = (user: UserOperation, actionType: string, label: string) => {
setOpDetailOpen(true);
setOpDetailTitle(`${user.user_name} · ${label}`);
setOpDetailLoading(true);
const { since, until } = rangeToParams(opDateKey, opCustomRange); const { since, until } = rangeToParams(opDateKey, opCustomRange);
fetchOperationDetail(user.user_id, actionType, since, until) opDetailRef.current?.open({
.then(setOpDetailList) userId: user.user_id,
.catch(() => setOpDetailList([])) userName: user.user_name,
.finally(() => setOpDetailLoading(false)); actionType,
label,
since,
until,
});
}; };
const onMsgSearch = (value: string) => { const onMsgSearch = (value: string) => {
@ -859,38 +860,8 @@ export default function AdminDashboard() {
)} )}
</Drawer> </Drawer>
{/* ═══ 操作明细抽屉 ═══ */} {/* ═══ 操作明细抽屉 — 公共组件,与大屏 ScreenDashboard 共用同一实现 ═══ */}
<Drawer <UserOperationDetailDrawer ref={opDetailRef} />
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>
</div> </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;
}