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:
52
backend/app/api/v1/endpoints/screen.py
Normal file
52
backend/app/api/v1/endpoints/screen.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
254
backend/app/services/screen_service.py
Normal file
254
backend/app/services/screen_service.py
Normal 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_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
|
||||||
|
],
|
||||||
|
)
|
||||||
@ -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 />}>
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
* 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */
|
* 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */
|
||||||
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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
114
frontend/src/components/admin/UserOperationDetailDrawer.tsx
Normal file
114
frontend/src/components/admin/UserOperationDetailDrawer.tsx
Normal 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;
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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,7 +162,10 @@ export default function MatrixBoard() {
|
|||||||
</div>
|
</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,
|
title: key,
|
||||||
dataIndex: key,
|
dataIndex: key,
|
||||||
align: "center" as const,
|
align: "center" as const,
|
||||||
@ -164,6 +188,7 @@ export default function MatrixBoard() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
title: "合计",
|
title: "合计",
|
||||||
dataIndex: "row_total",
|
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 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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
482
frontend/src/pages/admin/ScreenDashboard.tsx
Normal file
482
frontend/src/pages/admin/ScreenDashboard.tsx
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
/** 管理层数据大屏 — 全屏无侧边栏 · 亮色主题(与系统其他页面统一)· 60s 自动轮询
|
||||||
|
*
|
||||||
|
* 视角:日常运营与督导 —— 当月吞吐 / 当前卡点 / 系统活跃度。
|
||||||
|
*
|
||||||
|
* 布局:
|
||||||
|
* 顶部一排 4 张当月指标卡(生产流转 / 已入库 / 已出库 / 返厂回流)
|
||||||
|
* 左·主图区 【车间工序积压分布】横向柱状图,按生产段/售后段着色
|
||||||
|
* 右·侧边区 【系统活跃度排行】本月 接收 / 转交 / 备注 次数表(主体,撑满高度)
|
||||||
|
*
|
||||||
|
* 跳转:下钻一律用 window.open 开新标签页 —— 领导看板时不丢失大盘视图。
|
||||||
|
* 过滤:URL 参数直接映射到 AdminTasksPage 的原生筛选状态
|
||||||
|
* (?status= 点亮状态 Tabs、?stage= 点亮「当前工序」列筛选),
|
||||||
|
* 不引入任何大屏专用的额外 UI。
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { Maximize2, Minimize2, RefreshCw, ArrowLeft } from "lucide-react";
|
||||||
|
import type { EChartsCoreOption } from "echarts/core";
|
||||||
|
|
||||||
|
import BaseEChart from "../../components/BaseEChart";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
fetchMonthlyMetrics,
|
||||||
|
fetchWipDistribution,
|
||||||
|
fetchActiveUsers,
|
||||||
|
type MonthlyMetrics,
|
||||||
|
type WipDistributionResponse,
|
||||||
|
type ActiveUser,
|
||||||
|
type ActiveUsersResponse,
|
||||||
|
} from "../../services/screenApi";
|
||||||
|
import UserOperationDetailDrawer, {
|
||||||
|
type UserOperationDetailDrawerHandle,
|
||||||
|
} from "../../components/admin/UserOperationDetailDrawer";
|
||||||
|
|
||||||
|
// 大屏轮询间隔
|
||||||
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
// 亮色主题下的 ECharts 配色
|
||||||
|
const AXIS_COLOR = "#6b7280"; // 坐标轴文字 gray-500
|
||||||
|
const GRID_COLOR = "#e5e7eb"; // 网格线 gray-200
|
||||||
|
const TEXT_COLOR = "#374151"; // 标签文字 gray-700
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
backgroundColor: "rgba(255,255,255,0.97)",
|
||||||
|
borderColor: "#d1d5db",
|
||||||
|
textStyle: { color: "#1f2937" },
|
||||||
|
extraCssText: "box-shadow:0 4px 12px rgba(0,0,0,0.08);",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生产段 / 售后段的柱子配色
|
||||||
|
const COLOR_PRODUCTION = "#0891b2"; // cyan-600
|
||||||
|
const COLOR_AFTER_SALES = "#ea580c"; // orange-600
|
||||||
|
|
||||||
|
// 操作类型 → 中文名(明细抽屉标题用)
|
||||||
|
const OP_LABELS: Record<string, string> = {
|
||||||
|
receive: "接收",
|
||||||
|
transfer: "转交",
|
||||||
|
record: "上传备注",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 鉴权外壳 — 与 AdminLayout 同款守卫,但不渲染侧边栏
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function ScreenDashboard() {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-gray-50">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isAuthenticated) return <Navigate to="/admin/login" replace />;
|
||||||
|
|
||||||
|
return <ScreenBoard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 大屏主体
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function ScreenBoard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [metrics, setMetrics] = useState<MonthlyMetrics | null>(null);
|
||||||
|
const [dist, setDist] = useState<WipDistributionResponse | null>(null);
|
||||||
|
const [users, setUsers] = useState<ActiveUsersResponse | null>(null);
|
||||||
|
|
||||||
|
// 操作明细下钻 — 与「全局概览」AdminDashboard 的人员操作统计共用同一抽屉组件
|
||||||
|
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(null);
|
||||||
|
|
||||||
|
const [updatedAt, setUpdatedAt] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// 卸载后不再 setState;reqId 丢弃过期响应(防止慢请求覆盖新数据)
|
||||||
|
const aliveRef = useRef(true);
|
||||||
|
const reqIdRef = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const reqId = ++reqIdRef.current;
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const [m, d, u] = await Promise.all([
|
||||||
|
fetchMonthlyMetrics(),
|
||||||
|
fetchWipDistribution(),
|
||||||
|
fetchActiveUsers(10), // 多取一些,过滤掉 0 操作的人后再展示
|
||||||
|
]);
|
||||||
|
if (!aliveRef.current || reqId !== reqIdRef.current) return;
|
||||||
|
setMetrics(m);
|
||||||
|
setDist(d);
|
||||||
|
setUsers(u);
|
||||||
|
setError("");
|
||||||
|
setUpdatedAt(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
|
||||||
|
} catch {
|
||||||
|
if (!aliveRef.current || reqId !== reqIdRef.current) return;
|
||||||
|
setError("数据加载失败,将自动重试");
|
||||||
|
} finally {
|
||||||
|
if (aliveRef.current && reqId === reqIdRef.current) setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 首屏加载 + 60s 轮询
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true;
|
||||||
|
load();
|
||||||
|
const timer = setInterval(load, REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// 全屏状态同步(用户按 Esc 退出时图标要跟着变)
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener("fullscreenchange", onChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen();
|
||||||
|
else void document.documentElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下钻一律开新标签页 —— 领导看板时不丢失大盘视图 */
|
||||||
|
const openDrill = useCallback((url: string) => {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 柱状图下钻:点击某道工序 → 任务全景页点亮「当前工序」列筛选
|
||||||
|
const handleBarClick = useCallback((params: any) => {
|
||||||
|
const stage = params?.name;
|
||||||
|
if (!stage) return;
|
||||||
|
openDrill(`/admin/tasks?stage=${encodeURIComponent(stage)}`);
|
||||||
|
}, [openDrill]);
|
||||||
|
|
||||||
|
// ── 横向柱状图:车间工序积压分布 ──
|
||||||
|
const distOption = useMemo<EChartsCoreOption>(() => {
|
||||||
|
const items = dist?.items ?? [];
|
||||||
|
const total = dist?.total ?? 0;
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
axisPointer: { type: "shadow" },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const row = items[params[0]?.dataIndex ?? 0];
|
||||||
|
if (!row) return "";
|
||||||
|
const pct = total ? ((row.count / total) * 100).toFixed(1) : "0.0";
|
||||||
|
const phaseLabel = row.phase === "AFTER_SALES" ? "售后段" : "生产段";
|
||||||
|
return `${row.stage}(${phaseLabel})<br/><b>${row.count}</b> 台 · 占比 ${pct}%`;
|
||||||
|
},
|
||||||
|
...TOOLTIP_STYLE,
|
||||||
|
},
|
||||||
|
grid: { left: 6, right: 52, top: 8, bottom: 2, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: "value",
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: { color: AXIS_COLOR, fontSize: 11 },
|
||||||
|
splitLine: { lineStyle: { color: GRID_COLOR } },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: items.map((i) => i.stage),
|
||||||
|
inverse: true, // 首项(待启动)排在最上方,保持工序先后顺序
|
||||||
|
axisLine: { lineStyle: { color: "#d1d5db" } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: TEXT_COLOR, fontSize: 13 },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: "bar",
|
||||||
|
barMaxWidth: 26,
|
||||||
|
data: items.map((i) => i.count),
|
||||||
|
itemStyle: {
|
||||||
|
// 回调按生命周期阶段区分生产段 / 售后段
|
||||||
|
color: (p: any) => (items[p.dataIndex]?.phase === "AFTER_SALES"
|
||||||
|
? COLOR_AFTER_SALES
|
||||||
|
: COLOR_PRODUCTION),
|
||||||
|
borderRadius: [0, 4, 4, 0],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: "right",
|
||||||
|
color: TEXT_COLOR,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}, [dist]);
|
||||||
|
|
||||||
|
// 仅展示本月确实有操作的人员 —— 0 接收 / 0 转交 / 0 备注 一律隐藏
|
||||||
|
const activeUsers = (users?.items ?? []).filter((u) => u.total > 0);
|
||||||
|
const maxTotal = activeUsers.length ? Math.max(...activeUsers.map((u) => u.total)) : 1;
|
||||||
|
|
||||||
|
// 明细抽屉的时间范围取「本月」,与顶部卡片口径一致
|
||||||
|
const monthSince = metrics
|
||||||
|
? new Date(`${metrics.month_start}T00:00:00+08:00`).toISOString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function openUserDetail(u: ActiveUser, actionType: string, label: string) {
|
||||||
|
opDetailRef.current?.open({
|
||||||
|
userId: u.user_id,
|
||||||
|
userName: u.user_name,
|
||||||
|
actionType,
|
||||||
|
label,
|
||||||
|
since: monthSince,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整行点击:默认展开该人首个有记录的操作类型(与行内数字按钮同源) */
|
||||||
|
function openUserRow(u: ActiveUser) {
|
||||||
|
if (u.receive_count > 0) return openUserDetail(u, "receive", "接收");
|
||||||
|
if (u.transfer_count > 0) return openUserDetail(u, "transfer", "转交");
|
||||||
|
return openUserDetail(u, "record", "上传备注");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen flex-col overflow-hidden bg-gray-50 text-gray-800">
|
||||||
|
{/* ── 顶栏 ── */}
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-5 w-1.5 rounded-full bg-blue-600" />
|
||||||
|
<h1 className="text-lg font-bold tracking-wide text-gray-800">
|
||||||
|
生产流转 · 管理层数据大屏
|
||||||
|
</h1>
|
||||||
|
{metrics?.month && (
|
||||||
|
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{metrics.month} 当月
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span className="rounded bg-red-50 px-2 py-0.5 text-xs text-red-600">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span className="tabular-nums">最后更新 {updatedAt || "—"}</span>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||||
|
{isFullscreen ? "退出全屏" : "全屏"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin/dashboard")}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
返回后台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||||
|
{/* ── 顶部一排:4 张当月指标卡(点击新标签页下钻任务全景) ── */}
|
||||||
|
<div className="grid shrink-0 grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="本月生产流转"
|
||||||
|
value={metrics?.month_production ?? 0}
|
||||||
|
tone="text-cyan-600"
|
||||||
|
hint="本月有流转或新建的生产态设备"
|
||||||
|
onClick={() => openDrill("/admin/tasks?status=WIP")}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="本月已入库"
|
||||||
|
value={metrics?.month_inbound ?? 0}
|
||||||
|
tone="text-sky-600"
|
||||||
|
hint="扫码入库完成"
|
||||||
|
/* 直接点亮任务页原生的「已入库」Tab */
|
||||||
|
onClick={() => openDrill("/admin/tasks?status=ARCHIVED")}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="本月已出库"
|
||||||
|
value={metrics?.month_outbound ?? 0}
|
||||||
|
tone="text-green-600"
|
||||||
|
hint="扫码出库发货"
|
||||||
|
onClick={() => openDrill("/admin/tasks?status=OUTBOUND")}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="本月返厂回流"
|
||||||
|
value={metrics?.month_returned ?? 0}
|
||||||
|
tone="text-orange-600"
|
||||||
|
hint="本月转入售后阶段"
|
||||||
|
/* 直接点亮原生的「售后流转中」Tab */
|
||||||
|
onClick={() => openDrill("/admin/tasks?status=AFTER_SALES_WIP")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 主区:左积压分布 + 右活跃度 ── */}
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4">
|
||||||
|
{/* 左·主图区 */}
|
||||||
|
<section className="col-span-7 flex min-h-0 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<PanelTitle
|
||||||
|
extra={
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="flex items-center gap-1.5 text-gray-500">
|
||||||
|
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_PRODUCTION }} />
|
||||||
|
生产段
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-gray-500">
|
||||||
|
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_AFTER_SALES }} />
|
||||||
|
售后段
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
未完结合计 <b className="text-cyan-600">{dist?.total ?? 0}</b> 台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
车间工序积压分布
|
||||||
|
</PanelTitle>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<BaseEChart
|
||||||
|
option={distOption}
|
||||||
|
height="100%"
|
||||||
|
onEvents={{ click: handleBarClick }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 右·侧边区:系统活跃度排行(主体,撑满右侧高度) */}
|
||||||
|
<div className="col-span-5 flex min-h-0 flex-col">
|
||||||
|
<section className="flex min-h-0 flex-1 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<PanelTitle
|
||||||
|
extra={
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
本月活跃 {activeUsers.length} 人 · 点击查看明细
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
系统活跃度排行
|
||||||
|
</PanelTitle>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{activeUsers.length > 0 ? (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400">
|
||||||
|
<th className="pb-1.5 text-left font-normal">姓名</th>
|
||||||
|
<th className="w-10 pb-1.5 text-right font-normal">接收</th>
|
||||||
|
<th className="w-10 pb-1.5 text-right font-normal">转交</th>
|
||||||
|
<th className="w-10 pb-1.5 text-right font-normal">备注</th>
|
||||||
|
<th className="w-12 pb-1.5 text-right font-normal">合计</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activeUsers.map((u) => (
|
||||||
|
<tr
|
||||||
|
key={u.user_id}
|
||||||
|
title="点击查看该人员的操作明细"
|
||||||
|
onClick={() => openUserRow(u)}
|
||||||
|
className="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td className="py-1.5 pr-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 truncate text-gray-700">
|
||||||
|
{u.user_name}
|
||||||
|
</span>
|
||||||
|
{/* 胶囊条:相对活跃度 */}
|
||||||
|
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-100">
|
||||||
|
<div
|
||||||
|
className="h-1 rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${Math.max((u.total / maxTotal) * 100, 6)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 与「全局概览 · 人员操作统计」一致:点数字看对应类型的明细 */}
|
||||||
|
{([
|
||||||
|
["receive", u.receive_count],
|
||||||
|
["transfer", u.transfer_count],
|
||||||
|
["record", u.record_count],
|
||||||
|
] as const).map(([type, cnt]) => (
|
||||||
|
<td key={type} className="py-1.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // 不冒泡触发整行的默认下钻
|
||||||
|
openUserDetail(u, type, OP_LABELS[type]);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer tabular-nums text-gray-500 transition-colors hover:text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{cnt}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="py-1.5 text-right font-bold tabular-nums text-blue-600">{u.total}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||||
|
{users ? "本月暂无操作记录" : "加载中…"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 操作明细抽屉 — 与「全局概览」共用同一组件(亮色主题,与系统一致) */}
|
||||||
|
<UserOperationDetailDrawer ref={opDetailRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 局部小组件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function PanelTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex shrink-0 items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-gray-700">
|
||||||
|
<span className="h-3.5 w-1 rounded-full bg-blue-600" />
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
label, value, tone, hint, onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: string;
|
||||||
|
hint: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
role={onClick ? "button" : undefined}
|
||||||
|
className={`rounded-xl border border-gray-200 bg-white px-5 py-3 shadow-sm transition-colors ${
|
||||||
|
onClick ? "cursor-pointer hover:border-blue-400 hover:bg-blue-50/40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
<div className="mt-0.5 flex items-baseline gap-1.5">
|
||||||
|
<span className={`text-4xl font-bold tabular-nums ${tone}`}>{value}</span>
|
||||||
|
<span className="text-xs text-gray-400">台</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-gray-400">{hint}</p>
|
||||||
|
{onClick && <p className="mt-1 text-[10px] text-blue-500">点击下钻(新标签页)→</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/services/screenApi.ts
Normal file
62
frontend/src/services/screenApi.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user