feat: 新增效能分析看板(个人能力图谱 + 设备人员耗时对比)
- 前端:AnalyticsDashboard 页面 + BaseEChart 封装 + analyticsApi,路由与导航 - 后端:/analytics 系列接口(capability/flow/options/device-records) - 能力图谱单机颗粒度、流转对比按人堆叠识别瓶颈、点击下钻备注、筛选动态联动 - 依赖:引入 echarts,移除 echarts-for-react
This commit is contained in:
69
backend/app/api/v1/endpoints/analytics.py
Normal file
69
backend/app/api/v1/endpoints/analytics.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项)"""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.analytics_service import (
|
||||
get_capability_profile, CapabilityResponse,
|
||||
get_flow_compare, FlowResponse,
|
||||
get_analytics_options, AnalyticsOptions,
|
||||
get_device_records, DeviceRecord,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["效能分析"])
|
||||
|
||||
|
||||
@router.get("/capability", response_model=CapabilityResponse)
|
||||
async def capability_profile(
|
||||
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(可选)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_capability_profile(
|
||||
db, assignee_ids=ids, spec_models=specs,
|
||||
since=since_dt, until=until_dt,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/flow", response_model=FlowResponse)
|
||||
async def flow_compare(
|
||||
product_sns: str | None = Query(None, description="身份证,逗号分隔"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(无 product_sns 时按型号取最近设备)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。"""
|
||||
sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_flow_compare(db, product_sns=sns, spec_models=specs)
|
||||
|
||||
|
||||
@router.get("/options", response_model=AnalyticsOptions)
|
||||
async def analytics_options(
|
||||
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(联动过滤型号)"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(联动过滤人员)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""顶部筛选栏选项 — 负责人 + 规格型号,支持动态联动。"""
|
||||
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_analytics_options(db, assignee_ids=ids, spec_models=specs)
|
||||
|
||||
|
||||
@router.get("/device-records", response_model=list[DeviceRecord])
|
||||
async def device_records(
|
||||
product_sn: str = Query(..., description="设备身份证"),
|
||||
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(可选,用于过滤)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""某台设备的任务备注记录(含图片);可选按负责人过滤。"""
|
||||
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
|
||||
return await get_device_records(db, product_sn, assignee_ids=ids)
|
||||
@ -12,6 +12,7 @@ from app.api.v1.endpoints.upload import router as upload_router
|
||||
from app.api.v1.endpoints.records import router as records_router
|
||||
from app.api.v1.endpoints.notifications import router as notifications_router
|
||||
from app.api.v1.endpoints.app_version import router as app_version_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -27,3 +28,4 @@ api_router.include_router(upload_router)
|
||||
api_router.include_router(records_router)
|
||||
api_router.include_router(notifications_router)
|
||||
api_router.include_router(app_version_router)
|
||||
api_router.include_router(analytics_router)
|
||||
|
||||
501
backend/app/services/analytics_service.py
Normal file
501
backend/app/services/analytics_service.py
Normal file
@ -0,0 +1,501 @@
|
||||
"""效能分析服务 — 个人能力图谱 + 设备流转对比(ECharts 数据源)
|
||||
|
||||
数据来源:
|
||||
- Task 工序/任务节点,携带 assignee_id、received_at、completed_at → 计算单台耗时。
|
||||
- Product 设备身份证(sn)、规格型号(spec_model)、物料名,用于筛选与展示。
|
||||
- TaskRecord 备注时间线(本模块当前仅作耗时主数据补充,可按需在后续下钻中引入)。
|
||||
|
||||
耗时口径:
|
||||
- 单台真实耗时 = (completed_at or now) - (received_at or created_at),单位小时。
|
||||
- naive datetime 按 UTC 处理,统一换算北京时间。
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Schemas(与前端 src/services/analyticsApi.ts 对齐)
|
||||
# ============================================================
|
||||
|
||||
class CapabilityDataPoint(BaseModel):
|
||||
value: float | None # 该人员在该设备上的总耗时(未触及为 None,真实 0 为 0.0)
|
||||
spec_model: str # 规格型号
|
||||
status: str # 该设备当前状态 WIP/PENDING/COMPLETED/—
|
||||
first_received_at: str # 最早接收时间 YYYY-MM-DD HH:mm
|
||||
last_completed_at: str # 最后完成时间 YYYY-MM-DD HH:mm(无则空串)
|
||||
|
||||
|
||||
class CapabilityDevice(BaseModel):
|
||||
product_sn: str # 身份证
|
||||
external_serial: str | None # 产品序列号(业务序列号)
|
||||
spec_model: str # 规格型号
|
||||
|
||||
|
||||
class CapabilitySeries(BaseModel):
|
||||
name: str # 人员姓名
|
||||
assignee_id: str # 人员ID
|
||||
data: list[CapabilityDataPoint] # 与 categories 严格对齐,未触及设备补 0
|
||||
|
||||
|
||||
class CapabilityResponse(BaseModel):
|
||||
categories: list[str] # X 轴:设备身份证(按时间升序)
|
||||
devices: list[CapabilityDevice] # 与 categories 对齐的设备元数据
|
||||
series: list[CapabilitySeries]
|
||||
|
||||
|
||||
class FlowDevice(BaseModel):
|
||||
product_sn: str
|
||||
external_serial: str | None
|
||||
material_name: str
|
||||
spec_model: str
|
||||
|
||||
|
||||
class FlowSeries(BaseModel):
|
||||
name: str # 工序节点名
|
||||
data: list[float | None] # 每台设备在该工序的总耗时(未经过为 None)
|
||||
|
||||
|
||||
class FlowResponse(BaseModel):
|
||||
devices: list[FlowDevice]
|
||||
series: list[FlowSeries]
|
||||
|
||||
|
||||
class AssigneeOption(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class AnalyticsOptions(BaseModel):
|
||||
assignees: list[AssigneeOption]
|
||||
spec_models: list[str]
|
||||
|
||||
|
||||
class DeviceRecord(BaseModel):
|
||||
task_name: str # 工序名
|
||||
assignee_name: str # 负责人姓名
|
||||
status: str # 任务状态
|
||||
remark: str | None # 备注
|
||||
images: list[str] # 图片 URL 列表
|
||||
created_at: str # 记录时间 ISO
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 时间工具
|
||||
# ============================================================
|
||||
|
||||
def _to_bj(dt: datetime | None) -> datetime | None:
|
||||
"""naive datetime 按 UTC 处理,统一换算为北京时间。"""
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
if not dt:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt
|
||||
|
||||
|
||||
def _duration_hours(start: datetime | None, end: datetime) -> float:
|
||||
"""计算单台耗时(小时),无开始时间返回 0。"""
|
||||
if not start:
|
||||
return 0.0
|
||||
return (end - start).total_seconds() / 3600
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 个人能力图谱 — 单台设备耗时对比(分组柱状图,X=设备身份证)
|
||||
# ============================================================
|
||||
|
||||
async def get_capability_profile(
|
||||
db: AsyncSession,
|
||||
assignee_ids: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> CapabilityResponse:
|
||||
"""
|
||||
个人能力图谱(单机颗粒度):X 轴 = 设备身份证,每个负责人一条柱状 series。
|
||||
按 (负责人, 设备) 分组,累加该人在该设备所有工序的耗时,
|
||||
series.data 与 categories 严格对齐,未触及设备补 0。
|
||||
耗时口径:(coalesce(completed_at, now) - coalesce(received_at, created_at))。
|
||||
"""
|
||||
from app.models.task import (
|
||||
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Task.assignee_id, Task.status,
|
||||
Task.received_at, Task.created_at, Task.completed_at,
|
||||
Product.serial_number, Product.external_serial, Product.spec_model,
|
||||
)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
)
|
||||
if assignee_ids:
|
||||
stmt = stmt.where(Task.assignee_id.in_(assignee_ids))
|
||||
if spec_models:
|
||||
stmt = stmt.where(Product.spec_model.in_(spec_models))
|
||||
|
||||
# 时间交集(与 people-history 口径一致)
|
||||
start_expr = func.coalesce(Task.received_at, Task.created_at)
|
||||
end_expr = func.coalesce(Task.completed_at, now)
|
||||
if until:
|
||||
stmt = stmt.where(start_expr <= until)
|
||||
if since:
|
||||
stmt = stmt.where(end_expr >= since)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 设备元数据 + 时间基准;人员×设备 耗时聚合
|
||||
device_meta: dict[str, dict] = {}
|
||||
agg: dict[tuple[str, str], dict] = {}
|
||||
for row in rows:
|
||||
assignee = row[0]
|
||||
status = row[1]
|
||||
start = _to_bj(row[2] or row[3]) # received_at or created_at
|
||||
end = _to_bj(row[4]) if row[4] else now # completed_at or now
|
||||
sn = row[5]
|
||||
ext = row[6]
|
||||
spec = row[7] or "未知型号"
|
||||
|
||||
meta = device_meta.setdefault(sn, {"external_serial": ext, "spec_model": spec, "earliest": None})
|
||||
if start and (meta["earliest"] is None or start < meta["earliest"]):
|
||||
meta["earliest"] = start
|
||||
|
||||
entry = agg.setdefault((assignee, sn), {
|
||||
"hours": 0.0, "prio": 9,
|
||||
"first_start": None, "last_completed": None,
|
||||
})
|
||||
entry["hours"] += _duration_hours(start, end)
|
||||
if start and (entry["first_start"] is None or start < entry["first_start"]):
|
||||
entry["first_start"] = start
|
||||
completed_dt = _to_bj(row[4]) if row[4] else None
|
||||
if completed_dt and (entry["last_completed"] is None or completed_dt > entry["last_completed"]):
|
||||
entry["last_completed"] = completed_dt
|
||||
prio = 0 if status == TASK_STATUS_WIP else (1 if status == TASK_STATUS_PENDING else 2)
|
||||
entry["prio"] = min(entry["prio"], prio)
|
||||
|
||||
if not agg:
|
||||
return CapabilityResponse(categories=[], devices=[], series=[])
|
||||
|
||||
# X 轴:按最早接收/创建时间升序的设备身份证
|
||||
categories = sorted(device_meta.keys(), key=lambda s: device_meta[s]["earliest"] or now)
|
||||
devices = [
|
||||
CapabilityDevice(
|
||||
product_sn=sn,
|
||||
external_serial=device_meta[sn]["external_serial"],
|
||||
spec_model=device_meta[sn]["spec_model"],
|
||||
)
|
||||
for sn in categories
|
||||
]
|
||||
|
||||
# 人员姓名映射
|
||||
raw_ids = list({a for (a, _) in agg.keys()})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
STATUS_CODE = {0: "WIP", 1: "PENDING", 2: "COMPLETED"}
|
||||
|
||||
by_assignee: dict[str, dict[str, dict]] = {}
|
||||
for (a, sn), entry in agg.items():
|
||||
by_assignee.setdefault(a, {})[sn] = entry
|
||||
|
||||
series: list[CapabilitySeries] = []
|
||||
for a in sorted(by_assignee.keys()):
|
||||
sn_entries = by_assignee[a]
|
||||
data: list[CapabilityDataPoint] = []
|
||||
for sn in categories:
|
||||
e = sn_entries.get(sn)
|
||||
if e:
|
||||
data.append(CapabilityDataPoint(
|
||||
value=round(e["hours"], 1),
|
||||
spec_model=device_meta[sn]["spec_model"],
|
||||
status=STATUS_CODE[e["prio"]],
|
||||
first_received_at=e["first_start"].strftime("%Y-%m-%d %H:%M") if e["first_start"] else "",
|
||||
last_completed_at=e["last_completed"].strftime("%Y-%m-%d %H:%M") if e["last_completed"] else "",
|
||||
))
|
||||
else:
|
||||
data.append(CapabilityDataPoint(
|
||||
value=None,
|
||||
spec_model=device_meta[sn]["spec_model"],
|
||||
status="—",
|
||||
first_received_at="",
|
||||
last_completed_at="",
|
||||
))
|
||||
series.append(CapabilitySeries(
|
||||
name=name_map.get(a, a),
|
||||
assignee_id=a,
|
||||
data=data,
|
||||
))
|
||||
series.sort(key=lambda s: s.name)
|
||||
return CapabilityResponse(categories=categories, devices=devices, series=series)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 流转对比 — 设备各工序耗时(堆叠柱状)
|
||||
# ============================================================
|
||||
|
||||
async def get_flow_compare(
|
||||
db: AsyncSession,
|
||||
product_sns: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
) -> FlowResponse:
|
||||
"""
|
||||
查询每台设备上各操作人的累计耗时,拼装为堆叠柱状图:
|
||||
x 轴 = 设备身份证,每个「人员」一个 series,值为该员工在这台设备上的总耗时,
|
||||
谁的色块最长谁就是该设备的瓶颈。
|
||||
|
||||
设备来源:
|
||||
- 传 product_sns:按给定身份证;
|
||||
- 仅传 spec_models:这些型号下最近有流转的 20 台设备;
|
||||
- 都未传:返回空。
|
||||
|
||||
排序:人员按「最早介入该设备的真实时间」(received_at/created_at 最小值)升序堆叠。
|
||||
未参与某设备的人员返回 None(null),不补 0。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
if product_sns:
|
||||
product_rows = (await db.execute(
|
||||
select(
|
||||
Product.id, Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
).where(Product.serial_number.in_(product_sns))
|
||||
)).all()
|
||||
elif spec_models:
|
||||
latest_subq = (
|
||||
select(
|
||||
Task.product_id,
|
||||
func.max(func.coalesce(Task.received_at, Task.created_at)).label("latest"),
|
||||
)
|
||||
.group_by(Task.product_id)
|
||||
.subquery()
|
||||
)
|
||||
product_rows = (await db.execute(
|
||||
select(
|
||||
Product.id, Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
)
|
||||
.join(latest_subq, latest_subq.c.product_id == Product.id)
|
||||
.where(Product.spec_model.in_(spec_models))
|
||||
.order_by(latest_subq.c.latest.desc())
|
||||
.limit(20)
|
||||
)).all()
|
||||
else:
|
||||
return FlowResponse(devices=[], series=[])
|
||||
|
||||
if not product_rows:
|
||||
return FlowResponse(devices=[], series=[])
|
||||
|
||||
devices = [
|
||||
FlowDevice(
|
||||
product_sn=r[1], external_serial=r[2],
|
||||
material_name=r[3] or "", spec_model=r[4] or "",
|
||||
)
|
||||
for r in product_rows
|
||||
]
|
||||
# 显式传入身份证时按输入顺序排列
|
||||
if product_sns:
|
||||
order = {sn: i for i, sn in enumerate(product_sns)}
|
||||
devices.sort(key=lambda d: order.get(d.product_sn, len(order)))
|
||||
|
||||
id_to_sn = {r[0]: r[1] for r in product_rows}
|
||||
sn_to_index = {d.product_sn: i for i, d in enumerate(devices)}
|
||||
product_ids = [r[0] for r in product_rows]
|
||||
|
||||
# ── 这些设备的全部任务(按操作人聚合) ──
|
||||
task_rows = (await db.execute(
|
||||
select(
|
||||
Task.product_id, Task.assignee_id,
|
||||
Task.received_at, Task.created_at, Task.completed_at,
|
||||
)
|
||||
.where(
|
||||
Task.product_id.in_(product_ids),
|
||||
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
)).all()
|
||||
|
||||
# ── 聚合:人员(assignee_id) → {设备索引: 累计耗时},同时记录人员最早介入时间 ──
|
||||
agg: dict[str, dict[int, float]] = {}
|
||||
person_earliest: dict[str, datetime] = {}
|
||||
for row in task_rows:
|
||||
pid, assignee_id, received_at, created_at, completed_at = row
|
||||
sn = id_to_sn.get(pid)
|
||||
if sn is None or sn not in sn_to_index:
|
||||
continue
|
||||
idx = sn_to_index[sn]
|
||||
start = _to_bj(received_at or created_at)
|
||||
end = _to_bj(completed_at) if completed_at else now
|
||||
agg.setdefault(assignee_id, {}).setdefault(idx, 0.0)
|
||||
agg[assignee_id][idx] += _duration_hours(start, end)
|
||||
if start and (person_earliest.get(assignee_id) is None or start < person_earliest[assignee_id]):
|
||||
person_earliest[assignee_id] = start
|
||||
|
||||
# 翻译人员姓名
|
||||
raw_ids = list(agg.keys())
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
# ── 按人员最早介入时间升序排序(从下到上 = 谁先接手谁后接手) ──
|
||||
ordered_persons = sorted(agg.keys(), key=lambda p: person_earliest.get(p) or now)
|
||||
|
||||
series = [
|
||||
FlowSeries(
|
||||
name=name_map.get(p, p),
|
||||
data=[
|
||||
round(agg[p][i], 1) if i in agg[p] else None
|
||||
for i in range(len(devices))
|
||||
],
|
||||
)
|
||||
for p in ordered_persons
|
||||
]
|
||||
return FlowResponse(devices=devices, series=series)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设备备注记录 — 单台设备的全部备注/照片(弹窗下钻)
|
||||
# ============================================================
|
||||
|
||||
async def get_device_records(
|
||||
db: AsyncSession,
|
||||
product_sn: str,
|
||||
assignee_ids: list[str] | None = None,
|
||||
) -> list[DeviceRecord]:
|
||||
"""查询某台设备(身份证)的所有任务备注记录,含图片,按时间倒序。
|
||||
可选按负责人过滤(assignee_ids)。"""
|
||||
import json
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.product import Product
|
||||
|
||||
product_id = await db.scalar(
|
||||
select(Product.id).where(Product.serial_number == product_sn)
|
||||
)
|
||||
if not product_id:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
TaskRecord.remark, TaskRecord.images, TaskRecord.created_at,
|
||||
Task.task_name, Task.assignee_id, Task.status,
|
||||
)
|
||||
.join(Task, TaskRecord.task_id == Task.id)
|
||||
.where(Task.product_id == product_id)
|
||||
)
|
||||
if assignee_ids:
|
||||
stmt = stmt.where(Task.assignee_id.in_(assignee_ids))
|
||||
stmt = stmt.order_by(TaskRecord.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
raw_ids = list({r[4] for r in rows if r[4]})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
records: list[DeviceRecord] = []
|
||||
for row in rows:
|
||||
remark, images_raw, created, task_name, assignee, status = row
|
||||
try:
|
||||
images = json.loads(images_raw) if images_raw else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
images = []
|
||||
if not isinstance(images, list):
|
||||
images = []
|
||||
records.append(DeviceRecord(
|
||||
task_name=task_name or "",
|
||||
assignee_name=name_map.get(assignee or "", assignee or ""),
|
||||
status=status or "",
|
||||
remark=remark,
|
||||
images=images,
|
||||
created_at=_to_bj(created).isoformat() if created else "",
|
||||
))
|
||||
return records
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 下拉选项 — 负责人 + 规格型号
|
||||
# ============================================================
|
||||
|
||||
async def get_analytics_options(
|
||||
db: AsyncSession,
|
||||
assignee_ids: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
) -> AnalyticsOptions:
|
||||
"""返回筛选栏选项,支持动态联动:
|
||||
- 传入 spec_models:只返回碰过这些型号的人;
|
||||
- 传入 assignee_ids:只返回这些人处理过的型号。"""
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
|
||||
# 负责人:从 tasks 去重(可选按 spec_models 精确过滤)
|
||||
if spec_models:
|
||||
assignee_stmt = (
|
||||
select(Task.assignee_id)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Task.assignee_id.isnot(None),
|
||||
Product.spec_model.in_(spec_models),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
assignee_stmt = (
|
||||
select(Task.assignee_id)
|
||||
.where(Task.assignee_id.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
assignee_rows = (await db.execute(assignee_stmt)).all()
|
||||
distinct_assignees = sorted({r[0] for r in assignee_rows if r[0]})
|
||||
|
||||
name_map: dict[str, str] = {}
|
||||
if distinct_assignees:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(distinct_assignees)
|
||||
|
||||
assignees = [
|
||||
AssigneeOption(id=aid, name=name_map.get(aid, aid))
|
||||
for aid in distinct_assignees
|
||||
]
|
||||
|
||||
# 规格型号:从 products 去重(可选按 assignee_ids 过滤)
|
||||
if assignee_ids:
|
||||
spec_stmt = (
|
||||
select(Product.spec_model)
|
||||
.join(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
Product.spec_model.isnot(None),
|
||||
func.trim(Product.spec_model) != "",
|
||||
Task.assignee_id.in_(assignee_ids),
|
||||
)
|
||||
)
|
||||
else:
|
||||
spec_stmt = (
|
||||
select(Product.spec_model)
|
||||
.where(Product.spec_model.isnot(None), func.trim(Product.spec_model) != "")
|
||||
)
|
||||
spec_rows = (await db.execute(spec_stmt.distinct())).all()
|
||||
spec_models = sorted({r[0] for r in spec_rows if r[0]})
|
||||
|
||||
return AnalyticsOptions(assignees=assignees, spec_models=spec_models)
|
||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"antd": "^6.5.3",
|
||||
"axios": "^1.19.0",
|
||||
"echarts": "^5.6.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^1.28.0",
|
||||
"react": "^19.2.8",
|
||||
@ -2254,6 +2255,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
@ -3133,6 +3144,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
@ -3480,6 +3497,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
||||
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"antd": "^6.5.3",
|
||||
"axios": "^1.19.0",
|
||||
"echarts": "^5.6.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^1.28.0",
|
||||
"react": "^19.2.8",
|
||||
|
||||
@ -23,6 +23,7 @@ const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
|
||||
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
|
||||
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
|
||||
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
|
||||
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -53,6 +54,7 @@ export default function App() {
|
||||
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
||||
<Route path="/admin/people" element={<AdminPeoplePage />} />
|
||||
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
80
frontend/src/components/BaseEChart.tsx
Normal file
80
frontend/src/components/BaseEChart.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
/** 轻量级 ECharts 封装 — 原生 echarts/core + ResizeObserver 响应式缩放
|
||||
* 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts/core";
|
||||
import { LineChart, BarChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent,
|
||||
} from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
|
||||
// 按需注册(新增图表/组件时在此追加,避免全量打包)
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
type EChartsInstance = ReturnType<typeof echarts.init>;
|
||||
|
||||
interface BaseEChartProps {
|
||||
option: EChartsCoreOption;
|
||||
height?: number | string;
|
||||
className?: string;
|
||||
onEvents?: Record<string, (params: any) => void>;
|
||||
onZrClick?: (chart: EChartsInstance, event: any) => void;
|
||||
}
|
||||
|
||||
export default function BaseEChart({ option, height = 380, className, onEvents, onZrClick }: BaseEChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<EChartsInstance | null>(null);
|
||||
const eventsRef = useRef(onEvents);
|
||||
eventsRef.current = onEvents;
|
||||
const onZrClickRef = useRef(onZrClick);
|
||||
onZrClickRef.current = onZrClick;
|
||||
|
||||
// 初始化(仅一次):init → setOption → 注册事件 → ResizeObserver
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const chart = echarts.init(el);
|
||||
chartRef.current = chart;
|
||||
chart.setOption(option);
|
||||
|
||||
// 常规 ECharts 事件(读 eventsRef,保证始终用最新 handler)
|
||||
Object.keys(eventsRef.current ?? {}).forEach((event) => {
|
||||
chart.on(event, (params: any) => {
|
||||
eventsRef.current?.[event]?.(params);
|
||||
});
|
||||
});
|
||||
|
||||
// ZRender 底层点击(点击列阴影/背景也能触发,热区更大)
|
||||
chart.getZr().on("click", (e: any) => {
|
||||
onZrClickRef.current?.(chart, e);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(el);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
chartRef.current = null;
|
||||
};
|
||||
// 仅挂载时执行一次;option 更新交给下面的 effect
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// option 更新:整表替换,避免 merge 残留旧 series
|
||||
useEffect(() => {
|
||||
chartRef.current?.setOption(option, { notMerge: true });
|
||||
}, [option]);
|
||||
|
||||
return <div ref={containerRef} className={className} style={{ width: "100%", height }} />;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users } from "lucide-react";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3 } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const MENU = [
|
||||
@ -27,6 +27,12 @@ const MENU = [
|
||||
icon: Users,
|
||||
description: "按负责人查看在制品设备分布",
|
||||
},
|
||||
{
|
||||
title: "效能分析",
|
||||
path: "/admin/analytics",
|
||||
icon: BarChart3,
|
||||
description: "人员效能 / 设备流转 ECharts 可视化",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
580
frontend/src/pages/admin/AnalyticsDashboard.tsx
Normal file
580
frontend/src/pages/admin/AnalyticsDashboard.tsx
Normal file
@ -0,0 +1,580 @@
|
||||
/** 效能分析看板 — 独立的 ECharts 数据可视化页面(路由 /admin/analytics)
|
||||
* 个人能力图谱:X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { DatePicker, Tabs, Select, Input, Button, Empty, Modal, Timeline, Image } from "antd";
|
||||
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle } from "lucide-react";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
import BaseEChart from "../../components/BaseEChart";
|
||||
import {
|
||||
fetchCapabilityProfile, fetchFlowData, fetchAnalyticsOptions, fetchDeviceRecords,
|
||||
type CapabilityResponse, type FlowResponse, type AnalyticsOptions,
|
||||
type CapabilityQuery, type FlowQuery, type DeviceRecord,
|
||||
} from "../../services/analyticsApi";
|
||||
|
||||
dayjs.locale("zh-cn");
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// ─── 状态中文映射 ─────────────────────────────────────────
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
WIP: "进行中",
|
||||
PENDING: "待接收",
|
||||
COMPLETED: "已完成",
|
||||
"—": "未涉及",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
WIP: "bg-blue-100 text-blue-700",
|
||||
PENDING: "bg-amber-100 text-amber-700",
|
||||
COMPLETED: "bg-green-100 text-green-700",
|
||||
"—": "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
WIP: "blue",
|
||||
PENDING: "orange",
|
||||
COMPLETED: "green",
|
||||
"—": "gray",
|
||||
};
|
||||
|
||||
// ─── 图片 URL 拼接(对齐 AdminPeoplePage) ────────────────
|
||||
function imageUrl(u: string) {
|
||||
if (!u) return "";
|
||||
if (u.startsWith("http")) return u;
|
||||
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
const path = u.startsWith("/") ? u : "/" + u;
|
||||
if (path.startsWith("/api/")) {
|
||||
const origin = base.replace(/\/api(\/v\d+)?$/, "");
|
||||
return origin + path;
|
||||
}
|
||||
return base + path;
|
||||
}
|
||||
|
||||
export default function AnalyticsDashboard() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// ─── 筛选状态(从 URL 懒初始化) ─────────────────────────
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(() =>
|
||||
(searchParams.get("assignee_id") || "").split(",").filter(Boolean),
|
||||
);
|
||||
const [specModels, setSpecModels] = useState<string[]>(() =>
|
||||
(searchParams.get("spec_models") || searchParams.get("spec_model") || "").split(",").filter(Boolean),
|
||||
);
|
||||
const [productSn, setProductSn] = useState(() =>
|
||||
searchParams.get("product_sn") || searchParams.get("sn") || "",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState("capability");
|
||||
|
||||
// ─── 下拉选项 + 数据 ─────────────────────────────────────
|
||||
const [options, setOptions] = useState<AnalyticsOptions>({ assignees: [], spec_models: [] });
|
||||
const [capability, setCapability] = useState<CapabilityResponse | null>(null);
|
||||
const [capabilityLoading, setCapabilityLoading] = useState(false);
|
||||
const [flow, setFlow] = useState<FlowResponse | null>(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ─── 弹窗下钻:设备备注(all=true 表示流转图点击,展示全部) ──
|
||||
const [recordQuery, setRecordQuery] = useState<{ sn: string; all: boolean } | null>(null);
|
||||
const [records, setRecords] = useState<DeviceRecord[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
|
||||
// ─── 筛选状态 → 同步回 URL(跳过首帧) ──
|
||||
const skipSync = useRef(true);
|
||||
useEffect(() => {
|
||||
if (skipSync.current) {
|
||||
skipSync.current = false;
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (assigneeIds.length) params.set("assignee_id", assigneeIds.join(","));
|
||||
if (specModels.length) params.set("spec_models", specModels.join(","));
|
||||
if (productSn.trim()) params.set("product_sn", productSn.trim());
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [assigneeIds, specModels, productSn, setSearchParams]);
|
||||
|
||||
// ─── 构建查询 ────────────────────────────────────────────
|
||||
const buildCapabilityQuery = useCallback((): CapabilityQuery => {
|
||||
const q: CapabilityQuery = {};
|
||||
if (assigneeIds.length) q.assignee_ids = assigneeIds;
|
||||
if (specModels.length) q.spec_models = specModels;
|
||||
if (range) {
|
||||
q.since = range[0].startOf("day").toISOString();
|
||||
q.until = range[1].endOf("day").toISOString();
|
||||
}
|
||||
return q;
|
||||
}, [assigneeIds, specModels, range]);
|
||||
|
||||
const buildFlowQuery = useCallback((): FlowQuery => {
|
||||
const q: FlowQuery = {};
|
||||
const sns = productSn.split(/[,,\s]+/).filter(Boolean);
|
||||
if (sns.length) q.product_sns = sns;
|
||||
if (specModels.length) q.spec_models = specModels;
|
||||
return q;
|
||||
}, [productSn, specModels]);
|
||||
|
||||
// ─── 能力图谱(需选择人员才发起) ──
|
||||
const loadCapability = useCallback(async () => {
|
||||
if (assigneeIds.length === 0) {
|
||||
setCapability(null);
|
||||
return;
|
||||
}
|
||||
setCapabilityLoading(true);
|
||||
try {
|
||||
setCapability(await fetchCapabilityProfile(buildCapabilityQuery()));
|
||||
} catch {
|
||||
setError("加载数据失败,请确认后端已启动");
|
||||
} finally {
|
||||
setCapabilityLoading(false);
|
||||
}
|
||||
}, [assigneeIds, buildCapabilityQuery]);
|
||||
|
||||
// ─── 流转对比(需选型号或输入身份证才发起) ──
|
||||
const loadFlow = useCallback(async () => {
|
||||
if (specModels.length === 0 && productSn.trim().length === 0) {
|
||||
setFlow(null);
|
||||
return;
|
||||
}
|
||||
setFlowLoading(true);
|
||||
try {
|
||||
setFlow(await fetchFlowData(buildFlowQuery()));
|
||||
} catch {
|
||||
setError("加载数据失败,请确认后端已启动");
|
||||
} finally {
|
||||
setFlowLoading(false);
|
||||
}
|
||||
}, [specModels, productSn, buildFlowQuery]);
|
||||
|
||||
// ─── 下拉选项(动态联动:随 assigneeIds/specModels 变化重新拉取) ──
|
||||
useEffect(() => {
|
||||
fetchAnalyticsOptions(assigneeIds, specModels)
|
||||
.then(setOptions)
|
||||
.catch(() => {}); // 选项失败不阻塞主图表
|
||||
}, [assigneeIds, specModels]);
|
||||
|
||||
// ─── 数据自动加载(防抖 300ms) ──
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { loadCapability(); }, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [loadCapability]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { loadFlow(); }, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [loadFlow]);
|
||||
|
||||
// ─── 点击柱/背景 → 换算 dataIndex → 拉取该设备备注 ──
|
||||
const categoriesRef = useRef<string[]>([]);
|
||||
categoriesRef.current = capability?.categories ?? [];
|
||||
const flowSnRef = useRef<string[]>([]);
|
||||
flowSnRef.current = (flow?.devices ?? []).map((d) => d.product_sn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordQuery) return;
|
||||
setRecordsLoading(true);
|
||||
fetchDeviceRecords(recordQuery.sn, recordQuery.all ? undefined : assigneeIds)
|
||||
.then(setRecords)
|
||||
.catch(() => setRecords([]))
|
||||
.finally(() => setRecordsLoading(false));
|
||||
}, [recordQuery, assigneeIds]);
|
||||
|
||||
// 能力图谱:点柱子 → 按当前人员筛选过滤备注
|
||||
const handleZrClick = useCallback((chart: any, e: any) => {
|
||||
const coord = chart.convertFromPixel({ seriesIndex: 0 }, [e.offsetX, e.offsetY]);
|
||||
if (!coord || coord.length < 1) return;
|
||||
const dataIndex = Math.round(coord[0]);
|
||||
if (dataIndex < 0 || dataIndex >= categoriesRef.current.length) return;
|
||||
const sn = categoriesRef.current[dataIndex];
|
||||
if (sn) setRecordQuery({ sn, all: false });
|
||||
}, []);
|
||||
|
||||
// 流转对比:点柱子/背景 → 展示该设备全生命周期所有备注(不过滤人员)
|
||||
const handleFlowZrClick = useCallback((chart: any, e: any) => {
|
||||
const coord = chart.convertFromPixel({ seriesIndex: 0 }, [e.offsetX, e.offsetY]);
|
||||
if (!coord || coord.length < 1) return;
|
||||
const dataIndex = Math.round(coord[0]);
|
||||
if (dataIndex < 0 || dataIndex >= flowSnRef.current.length) return;
|
||||
const sn = flowSnRef.current[dataIndex];
|
||||
if (sn) setRecordQuery({ sn, all: true });
|
||||
}, []);
|
||||
|
||||
const handleReset = () => {
|
||||
setRange(null);
|
||||
setAssigneeIds([]);
|
||||
setSpecModels([]);
|
||||
setProductSn("");
|
||||
};
|
||||
|
||||
// ─── 柱状图:个人能力图谱(X=设备身份证,系列=人员,并排对比) ──
|
||||
const capabilityOption = useMemo<EChartsCoreOption>(() => {
|
||||
const categories = capability?.categories ?? [];
|
||||
const devices = capability?.devices ?? [];
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
formatter: (p: any) => {
|
||||
const items = Array.isArray(p) ? p : [p];
|
||||
const idx = items[0]?.dataIndex ?? 0;
|
||||
const sn = categories[idx] ?? "—";
|
||||
const dev = devices[idx];
|
||||
const ext = dev?.external_serial;
|
||||
const rows = items
|
||||
.filter((it: any) => (it.data?.status ?? "—") !== "—")
|
||||
.map((it: any) => {
|
||||
const d = it.data ?? {};
|
||||
const st = STATUS_LABEL[d.status] ?? d.status;
|
||||
const recv = d.first_received_at
|
||||
? `<br/>接收时间:${dayjs(d.first_received_at).format("MM-DD HH:mm")}`
|
||||
: "";
|
||||
const done = d.last_completed_at
|
||||
? `<br/>结束时间:${dayjs(d.last_completed_at).format("MM-DD HH:mm")}`
|
||||
: "";
|
||||
return `${it.marker}${it.seriesName}:耗时 ${d.value ?? 0} 小时(${st})${recv}${done}`;
|
||||
})
|
||||
.join("<br/>");
|
||||
return (
|
||||
`设备身份证:${sn}` +
|
||||
(ext ? `<br/>产品序列号:${ext}` : "") +
|
||||
`<br/>规格型号:${dev?.spec_model ?? "—"}<br/>${rows}`
|
||||
);
|
||||
},
|
||||
},
|
||||
legend: { top: 0, type: "scroll" },
|
||||
grid: { left: 48, right: 24, top: 40, bottom: 72 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
|
||||
axisLabel: { fontSize: 10, interval: 0 },
|
||||
},
|
||||
yAxis: { type: "value", name: "耗时(小时)" },
|
||||
series: (capability?.series ?? []).map((s) => ({
|
||||
name: s.name,
|
||||
type: "bar" as const,
|
||||
barMaxWidth: 24,
|
||||
barMinHeight: 3,
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
formatter: (p: any) => (p.value == null ? "" : `${p.value}h`),
|
||||
},
|
||||
markLine: { data: [{ type: "average", name: "平均耗时" }] },
|
||||
data: s.data.map((d, i) =>
|
||||
d.value == null
|
||||
? null
|
||||
: {
|
||||
value: d.value,
|
||||
spec_model: d.spec_model,
|
||||
status: d.status,
|
||||
first_received_at: d.first_received_at,
|
||||
last_completed_at: d.last_completed_at,
|
||||
product_sn: categories[i],
|
||||
external_serial: devices[i]?.external_serial,
|
||||
},
|
||||
),
|
||||
})),
|
||||
};
|
||||
}, [capability]);
|
||||
|
||||
// ─── 有数据的日期集合(用于日历蓝点) ──
|
||||
const activityDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const s of capability?.series ?? []) {
|
||||
for (const d of s.data) {
|
||||
if (d.first_received_at) set.add(dayjs(d.first_received_at).format("YYYY-MM-DD"));
|
||||
if (d.last_completed_at) set.add(dayjs(d.last_completed_at).format("YYYY-MM-DD"));
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [capability]);
|
||||
|
||||
// ─── 柱状图:设备人员耗时对比(按人堆叠 + 柱顶总计 + tooltip 揪元凶) ──
|
||||
const flowOption = useMemo<EChartsCoreOption>(() => {
|
||||
const devices = flow?.devices ?? [];
|
||||
const series = flow?.series ?? [];
|
||||
// 每台设备总耗时(各人员累加)
|
||||
const totalData = devices.map((_, i) => {
|
||||
let sum = 0;
|
||||
for (const s of series) {
|
||||
const v = s.data[i];
|
||||
if (v != null) sum += v;
|
||||
}
|
||||
return Math.round(sum * 10) / 10;
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
formatter: (p: any) => {
|
||||
const raw = Array.isArray(p) ? p : [p];
|
||||
const idx = raw[0]?.dataIndex ?? 0;
|
||||
const items = raw.filter((it: any) => it.seriesName !== "总计" && it.value != null);
|
||||
const d = devices[idx];
|
||||
const total = items.reduce((sum: number, it: any) => sum + it.value, 0);
|
||||
const head = d
|
||||
? `${d.material_name || "设备"} · ${d.spec_model || "—"}<br/>身份证:${d.product_sn}<br/>`
|
||||
: "";
|
||||
const body = items
|
||||
.map((it: any) => {
|
||||
const pct = total > 0 ? ((it.value / total) * 100).toFixed(1) : "0.0";
|
||||
return `${it.marker}${it.seriesName}:${it.value} 小时(${pct}%)`;
|
||||
})
|
||||
.join("<br/>");
|
||||
return head + body;
|
||||
},
|
||||
},
|
||||
legend: { top: 0, type: "scroll", data: series.map((s) => s.name) },
|
||||
grid: { left: 48, right: 24, top: 40, bottom: 72 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: devices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
|
||||
axisLabel: { fontSize: 10, interval: 0 },
|
||||
},
|
||||
yAxis: { type: "value", name: "耗时(小时)" },
|
||||
series: [
|
||||
...series.map((s, si) => ({
|
||||
name: s.name,
|
||||
type: "bar" as const,
|
||||
stack: "total",
|
||||
barMaxWidth: 40,
|
||||
itemStyle: si === series.length - 1 ? { borderRadius: [4, 4, 0, 0] } : undefined,
|
||||
data: s.data,
|
||||
})),
|
||||
{
|
||||
name: "总计",
|
||||
type: "bar" as const,
|
||||
barGap: "-100%",
|
||||
barMaxWidth: 40,
|
||||
itemStyle: { color: "transparent" },
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
formatter: "{c}h",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
},
|
||||
data: totalData,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [flow]);
|
||||
|
||||
const capabilityEmpty = !capability || capability.series.length === 0;
|
||||
const flowEmpty = !flow || flow.devices.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">📊 效能分析看板</h2>
|
||||
<p className="mt-0.5 text-sm text-gray-400">个人能力图谱 / 设备流转对比 · ECharts 可视化</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 顶部统一筛选栏 */}
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RangePicker
|
||||
size="small"
|
||||
value={range as any}
|
||||
onChange={(dates) => setRange(dates as [Dayjs, Dayjs] | null)}
|
||||
disabledDate={(d) => d.isAfter(dayjs(), "day")}
|
||||
cellRender={(current, info) => {
|
||||
if (info.type !== "date") return info.originNode;
|
||||
const day = current as Dayjs;
|
||||
const hasDot = activityDates.has(day.format("YYYY-MM-DD"));
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{day.date()}</span>
|
||||
{hasDot && <span className="h-1 w-1 rounded-full bg-blue-500" />}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
style={{ width: 240 }}
|
||||
placeholder={["开始日期", "结束日期"]}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
size="small"
|
||||
allowClear
|
||||
placeholder="人员(可多选)"
|
||||
value={assigneeIds}
|
||||
onChange={setAssigneeIds}
|
||||
options={options.assignees.map((a) => ({ value: a.id, label: `${a.name} (${a.id})` }))}
|
||||
style={{ minWidth: 160, maxWidth: 240 }}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={4}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
size="small"
|
||||
allowClear
|
||||
placeholder="规格型号"
|
||||
value={specModels}
|
||||
onChange={setSpecModels}
|
||||
options={options.spec_models.map((s) => ({ value: s, label: s }))}
|
||||
style={{ minWidth: 160, maxWidth: 240 }}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={8}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
allowClear
|
||||
placeholder="设备身份证(可多个,逗号分隔)"
|
||||
value={productSn}
|
||||
onChange={(e) => setProductSn(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={() => { loadCapability(); loadFlow(); }}>
|
||||
查询
|
||||
</Button>
|
||||
<Button size="small" icon={<RefreshCw className="h-3 w-3" />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主体:两个可视化区块 */}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: "capability",
|
||||
label: (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
个人能力图谱
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">个人能力图谱(单台设备耗时 · 点击柱子查看备注)</h3>
|
||||
{capabilityLoading && !capability ? (
|
||||
<div className="flex h-[420px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : capabilityEmpty ? (
|
||||
<div className="flex h-[420px] items-center justify-center">
|
||||
<Empty
|
||||
description={assigneeIds.length === 0 ? "请先选择人员查看能力图谱" : "暂无数据"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<BaseEChart
|
||||
option={capabilityOption}
|
||||
height={420}
|
||||
onZrClick={handleZrClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "flow",
|
||||
label: (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
设备流转对比
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">设备人员耗时对比 (按人堆叠)</h3>
|
||||
{flowLoading && !flow ? (
|
||||
<div className="flex h-[380px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : flowEmpty ? (
|
||||
<div className="flex h-[380px] items-center justify-center">
|
||||
<Empty
|
||||
description={specModels.length === 0 && productSn.trim().length === 0 ? "请先选择规格型号或输入设备身份证查看流转" : "暂无数据"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<BaseEChart option={flowOption} height={380} onZrClick={handleFlowZrClick} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 设备备注弹窗 */}
|
||||
<Modal
|
||||
title={`设备备注记录 — ${recordQuery?.sn ?? ""}`}
|
||||
open={!!recordQuery}
|
||||
onCancel={() => setRecordQuery(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{recordsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-400">暂无备注记录</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={records.map((r) => ({
|
||||
color: STATUS_DOT[r.status] ?? "gray",
|
||||
children: (
|
||||
<div>
|
||||
{/* 头部:工序名 · 操作人 + 状态徽章 + 时间 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800">{r.task_name || "—"}</span>
|
||||
<span className="text-xs text-gray-400">· {r.assignee_name || "—"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[r.status] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
{r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 备注文本 */}
|
||||
{r.remark && (
|
||||
<div className="mt-1.5 text-sm leading-relaxed text-gray-700">{r.remark}</div>
|
||||
)}
|
||||
|
||||
{/* 图片:PreviewGroup 包裹,点击全屏放大预览 */}
|
||||
{r.images && r.images.length > 0 && (
|
||||
<Image.PreviewGroup>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{r.images.map((img, j) => (
|
||||
<Image
|
||||
key={j}
|
||||
src={imageUrl(img)}
|
||||
alt={`备注图片 ${j + 1}`}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-md object-cover"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
frontend/src/services/analyticsApi.ts
Normal file
122
frontend/src/services/analyticsApi.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import api from "./api";
|
||||
|
||||
// ============================================================
|
||||
// 效能分析看板 — 前后端数据契约类型 + API
|
||||
// 对应后端 app/services/analytics_service.py 的 Pydantic Schema
|
||||
// ============================================================
|
||||
|
||||
// ─── 个人能力图谱(单机颗粒度分组柱状图) ──────────────────
|
||||
export interface CapabilityDataPoint {
|
||||
value: number | null; // 该人员在该设备上的总耗时(未触及为 null,真实 0 为 0)
|
||||
spec_model: string; // 规格型号
|
||||
status: string; // 该设备当前状态 WIP/PENDING/COMPLETED/—
|
||||
first_received_at: string; // 最早接收时间 YYYY-MM-DD HH:mm
|
||||
last_completed_at: string; // 最后完成时间 YYYY-MM-DD HH:mm(无则空串)
|
||||
}
|
||||
|
||||
export interface CapabilityDevice {
|
||||
product_sn: string; // 身份证
|
||||
external_serial: string | null; // 产品序列号(业务序列号)
|
||||
spec_model: string;
|
||||
}
|
||||
|
||||
export interface CapabilitySeries {
|
||||
name: string; // 人员姓名
|
||||
assignee_id: string; // 人员ID
|
||||
data: CapabilityDataPoint[]; // 与 categories 严格对齐,未触及设备补 0
|
||||
}
|
||||
|
||||
export interface CapabilityResponse {
|
||||
categories: string[]; // X 轴:设备身份证(按时间升序)
|
||||
devices: CapabilityDevice[]; // 与 categories 对齐的设备元数据
|
||||
series: CapabilitySeries[];
|
||||
}
|
||||
|
||||
// ─── 设备流转对比(堆叠柱状) ──────────────────────────────
|
||||
export interface FlowDevice {
|
||||
product_sn: string;
|
||||
external_serial: string | null;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
}
|
||||
|
||||
export interface FlowSeries {
|
||||
name: string; // 工序节点名
|
||||
data: (number | null)[]; // 每台设备在该工序的总耗时(未经过为 null)
|
||||
}
|
||||
|
||||
export interface FlowResponse {
|
||||
devices: FlowDevice[];
|
||||
series: FlowSeries[];
|
||||
}
|
||||
|
||||
// ─── 设备备注记录(弹窗下钻) ──────────────────────────────
|
||||
export interface DeviceRecord {
|
||||
task_name: string; // 工序名
|
||||
assignee_name: string; // 负责人姓名
|
||||
status: string; // 任务状态
|
||||
remark: string | null; // 备注
|
||||
images: string[]; // 图片 URL 列表
|
||||
created_at: string; // 记录时间 ISO
|
||||
}
|
||||
|
||||
// ─── 顶部下拉选项 ──────────────────────────────────────────
|
||||
export interface AssigneeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsOptions {
|
||||
assignees: AssigneeOption[];
|
||||
spec_models: string[];
|
||||
}
|
||||
|
||||
// ─── 查询参数 ──────────────────────────────────────────────
|
||||
export interface CapabilityQuery {
|
||||
assignee_ids?: string[];
|
||||
spec_models?: string[];
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface FlowQuery {
|
||||
product_sns?: string[];
|
||||
spec_models?: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 方法
|
||||
// ============================================================
|
||||
|
||||
export async function fetchCapabilityProfile(query: CapabilityQuery): Promise<CapabilityResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (query.assignee_ids?.length) params.assignee_ids = query.assignee_ids.join(",");
|
||||
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
|
||||
if (query.since) params.since = query.since;
|
||||
if (query.until) params.until = query.until;
|
||||
const { data } = await api.get<CapabilityResponse>("/analytics/capability", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchFlowData(query: FlowQuery): Promise<FlowResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (query.product_sns?.length) params.product_sns = query.product_sns.join(",");
|
||||
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
|
||||
const { data } = await api.get<FlowResponse>("/analytics/flow", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDeviceRecords(product_sn: string, assigneeIds?: string[]): Promise<DeviceRecord[]> {
|
||||
const params: Record<string, string> = { product_sn };
|
||||
if (assigneeIds?.length) params.assignee_ids = assigneeIds.join(",");
|
||||
const { data } = await api.get<DeviceRecord[]>("/analytics/device-records", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsOptions(assigneeIds?: string[], specModels?: string[]): Promise<AnalyticsOptions> {
|
||||
const params: Record<string, string> = {};
|
||||
if (assigneeIds?.length) params.assignee_ids = assigneeIds.join(",");
|
||||
if (specModels?.length) params.spec_models = specModels.join(",");
|
||||
const { data } = await api.get<AnalyticsOptions>("/analytics/options", { params });
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user