Compare commits

...

10 Commits

Author SHA1 Message Date
620df5ce11 feat(analytics): 人员视图重构为 custom 居中紧凑柱状 + 原生横向滚动
- capabilityOption 改 custom 系列,居中紧凑算法消除幽灵占位
- 柱宽保底 3px(0值/极小值也有柱子)、数字标签悬浮柱顶
- X 轴四行展示:物料名/规格型号/序列号/身份证
- 原生横向滚动容器(每设备 140px)+ custom-scrollbar 样式
- tooltip appendToBody 避免被容器裁剪
2026-08-14 15:43:31 +08:00
1e374e76bf feat(analytics): CapabilityDevice 增加 material_name(X 轴展示物料名)
- 后端 CapabilityDevice 新增 material_name,get_capability_profile 查询并填充
- 前端类型同步 material_name
2026-08-14 15:43:22 +08:00
00741ae288 fix(analytics): 柱色对齐图例、刷新智能定位 tab、视图更名
- custom series 补 itemStyle.color,使柱色与 legend/tooltip 色点一致
- activeTab 根据 URL 参数智能初始化(选人→人员视图,选设备→轨迹流转)
- 视图及页头文案从「个人能力图谱/设备流转对比」改为「人员视图/轨迹流转」
2026-08-14 14:49:56 +08:00
31d9a8781a feat(analytics): 筛选器体验升级(人名网格化/规格物料名/设备卡片下拉)
- 人员选择器 4 列网格 + 两字姓名对齐 + 全选/全不选
- 规格型号 2 列网格 + 物料名展示 + 全选/全不选
- 设备输入框改为卡片式下拉器(物料名/S/N/完整身份证)
- productSn(string) 改为 productSns(string[]) 多选
2026-08-14 14:30:43 +08:00
807df56c3a feat(analytics): 规格型号带物料名 + 设备字典(/options 数据契约扩展)
- 后端新增 SpecModelOption/DeviceOption schema
- get_analytics_options 返回规格型号的物料名(同型号取首个非空名)+ 关联设备列表
- 前端同步 SpecModelOption/DeviceOption 类型
2026-08-14 14:30:35 +08:00
7d48daca0c style(analytics): 隐去设备生命周期时间轴主干背景,仅保留柱顶总计标签
- 主干 series 的 itemStyle.color 改为 transparent,去掉灰色填充与描边
- 保留 label 的 {title|总计}\n{value|...} 文本,让总时长优雅悬浮在时间轴顶端
2026-08-14 11:46:44 +08:00
99235d29e0 feat(analytics): 设备生命周期时间轴(主干居中 + 主次分支 + 上帝视角 tooltip)
- flowOption 从堆叠柱状改为 custom 区间甘特图
- 灰色主干打底居中,主线任务绝对居中、分支任务悬挂右侧(靠透明度区分并行)
- tooltip 上帝视角:开始时间/流转周期/累计工时/中间空闲时长/人员占比(色点标识)
2026-08-14 11:44:55 +08:00
2b17dde42e feat(analytics): 前端 ECharts CustomChart 注册 + 数据契约类型扩展
- BaseEChart 注册 CustomChart 支持区间渲染
- FlowInterval 类型扩为 6 位(含 isMain)
- FlowDevice 增加 lead_time/started_at
2026-08-14 11:44:48 +08:00
74d404cc11 feat(analytics): 设备流转甘特图数据底座(lead_time/started_at/task_type 主次标识)
- FlowDevice 新增 lead_time(生命周期总时长)、started_at(最早介入时间)
- FlowSeries.data 改为区间数组 [device_index, start_offset, end_offset, task_name, duration, is_main]
- get_flow_compare 按任务生成相对 T0 的偏移区间,task_type=SPAWN 标记为分支
2026-08-14 11:44:41 +08:00
54b5ea00f1 feat: 新增效能分析看板(个人能力图谱 + 设备人员耗时对比)
- 前端:AnalyticsDashboard 页面 + BaseEChart 封装 + analyticsApi,路由与导航
- 后端:/analytics 系列接口(capability/flow/options/device-records)
- 能力图谱单机颗粒度、流转对比按人堆叠识别瓶颈、点击下钻备注、筛选动态联动
- 依赖:引入 echarts,移除 echarts-for-react
2026-08-14 10:19:44 +08:00
11 changed files with 1765 additions and 1 deletions

View 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)

View File

@ -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)

View File

@ -0,0 +1,607 @@
"""效能分析服务 — 个人能力图谱 + 设备流转对比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 # 规格型号
material_name: 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
lead_time: float # 设备生命周期总时长(小时)= max_end - min_start
started_at: str # 设备最早介入时间T0格式 MM-DD HH:mm
class FlowSeries(BaseModel):
name: str # 人员姓名
data: list[list] # 每项 = [device_index, start_offset, end_offset, task_name, duration](小时)
class FlowResponse(BaseModel):
devices: list[FlowDevice]
series: list[FlowSeries]
class AssigneeOption(BaseModel):
id: str
name: str
class SpecModelOption(BaseModel):
spec_model: str
material_name: str
class DeviceOption(BaseModel):
product_sn: str
external_serial: str | None
material_name: str
spec_model: str
class AnalyticsOptions(BaseModel):
assignees: list[AssigneeOption]
spec_models: list[SpecModelOption]
devices: list[DeviceOption]
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,
Product.material_name,
)
.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 "未知型号"
mat = row[8] or ""
meta = device_meta.setdefault(sn, {"external_serial": ext, "spec_model": spec, "material_name": mat, "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"],
material_name=device_meta[sn]["material_name"],
)
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 轴 = 设备身份证y 轴 = 相对该设备 T0最早介入时间的小时偏移。
每个任务一根悬空区间柱start_offset -> end_offset并行任务可并排显示。
设备来源:
- 传 product_sns按给定身份证
- 仅传 spec_models这些型号下最近有流转的 20 台设备;
- 都未传:返回空。
data 每项 = [device_index, start_offset, end_offset, task_name, duration](单位小时)。
"""
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 "",
lead_time=0.0,
started_at="",
)
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.task_name,
Task.received_at, Task.created_at, Task.completed_at,
Task.task_type,
)
.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()
# ── 解析为区间记录,并计算每台设备的 T0最早与 max_end最晚 ──
intervals: list[dict] = []
t0_by_device: dict[int, datetime] = {}
max_end_by_device: dict[int, datetime] = {}
for row in task_rows:
pid, assignee_id, task_name, received_at, created_at, completed_at, task_type = 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
if start is None:
continue
is_main = 0 if task_type == "SPAWN" else 1
intervals.append({
"idx": idx,
"assignee_id": assignee_id,
"task_name": (task_name or "").strip() or "未命名工序",
"start": start,
"end": end,
"is_main": is_main,
})
if idx not in t0_by_device or start < t0_by_device[idx]:
t0_by_device[idx] = start
if idx not in max_end_by_device or end > max_end_by_device[idx]:
max_end_by_device[idx] = end
# ── 计算每台设备 lead_time最大 end - 最小 start小时重建 devices ──
lead_time_by_index = {
idx: round((max_end_by_device[idx] - t0_by_device[idx]).total_seconds() / 3600, 1)
for idx in t0_by_device
}
devices = [
FlowDevice(
product_sn=d.product_sn, external_serial=d.external_serial,
material_name=d.material_name, spec_model=d.spec_model,
lead_time=lead_time_by_index.get(i, 0.0),
started_at=t0_by_device[i].strftime("%m-%d %H:%M") if i in t0_by_device else "",
)
for i, d in enumerate(devices)
]
# ── 按人分组,转为相对 T0 的小时偏移区间 ──
by_assignee: dict[str, list[list]] = {}
for it in intervals:
t0 = t0_by_device[it["idx"]]
start_offset = round((it["start"] - t0).total_seconds() / 3600, 1)
end_offset = round((it["end"] - t0).total_seconds() / 3600, 1)
duration = round(end_offset - start_offset, 1)
by_assignee.setdefault(it["assignee_id"], []).append(
[it["idx"], start_offset, end_offset, it["task_name"], duration, it["is_main"]]
)
# 翻译人员姓名
raw_ids = list(by_assignee.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)
series = [
FlowSeries(name=name_map.get(a, a), data=by_assignee[a])
for a in sorted(by_assignee.keys())
]
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只返回这些人处理过的型号
- devices根据筛选条件返回关联设备无筛选则返回最近流转的设备"""
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
]
# ── 规格型号 + 物料名(可选按 assignee_ids 过滤;同型号取首个非空物料名) ──
if assignee_ids:
spec_stmt = (
select(Product.spec_model, Product.material_name)
.join(Task, Task.product_id == Product.id)
.where(
Product.spec_model.isnot(None),
func.trim(Product.spec_model) != "",
Task.assignee_id.in_(assignee_ids),
)
.distinct()
)
else:
spec_stmt = (
select(Product.spec_model, Product.material_name)
.where(Product.spec_model.isnot(None), func.trim(Product.spec_model) != "")
.distinct()
)
spec_rows = (await db.execute(spec_stmt)).all()
spec_map: dict[str, str] = {}
for row in spec_rows:
sm = row[0]
mn = (row[1] or "").strip()
if sm not in spec_map:
spec_map[sm] = mn
elif mn and not spec_map[sm]:
spec_map[sm] = mn
spec_model_opts = [
SpecModelOption(spec_model=sm, material_name=spec_map[sm])
for sm in sorted(spec_map.keys())
]
# ── 设备字典(可选按 assignee_ids / spec_models 过滤;无筛选返回最近流转 100 台) ──
if assignee_ids or spec_models:
device_stmt = select(
Product.serial_number, Product.external_serial,
Product.material_name, Product.spec_model,
)
if assignee_ids:
device_stmt = device_stmt.join(
Task, Task.product_id == Product.id
).where(Task.assignee_id.in_(assignee_ids))
if spec_models:
device_stmt = device_stmt.where(Product.spec_model.in_(spec_models))
device_stmt = device_stmt.distinct()
else:
latest_subq = (
select(
Task.product_id,
func.max(func.coalesce(Task.received_at, Task.created_at)).label("latest"),
)
.group_by(Task.product_id)
.subquery()
)
device_stmt = (
select(
Product.serial_number, Product.external_serial,
Product.material_name, Product.spec_model,
)
.join(latest_subq, latest_subq.c.product_id == Product.id)
.order_by(latest_subq.c.latest.desc())
.limit(100)
)
device_rows = (await db.execute(device_stmt)).all()
devices = [
DeviceOption(
product_sn=r[0] or "",
external_serial=r[1] or None,
material_name=r[2] or "",
spec_model=r[3] or "",
)
for r in device_rows
]
return AnalyticsOptions(assignees=assignees, spec_models=spec_model_opts, devices=devices)

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -0,0 +1,81 @@
/** 轻量级 ECharts 封装 — 原生 echarts/core + ResizeObserver 响应式缩放
* 支持 onEvents常规事件与 onZrClickZRender 底层点击,扩大热区)。 */
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { LineChart, BarChart, CustomChart } 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,
CustomChart,
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 }} />;
}

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users } 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() {

View File

@ -53,3 +53,23 @@ body {
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
/* ============================================================
自定义横向滚动条(人员视图图表)
============================================================ */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: #f1f5f9;
border-radius: 3px;
}

View File

@ -0,0 +1,809 @@
/** 效能分析看板 — 独立的 ECharts 数据可视化页面(路由 /admin/analytics
* 个人能力图谱X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DatePicker, Tabs, Select, 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;
}
// ─── 中文姓名对齐:两字名字中间插入全角空格,与三字名视觉对齐 ──
function alignName(name: string) {
return name.length === 2 ? name[0] + "" + name[1] : name;
}
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 [productSns, setProductSns] = useState<string[]>(() =>
(searchParams.get("product_sns") || searchParams.get("product_sn") || searchParams.get("sn") || "")
.split(",").filter(Boolean),
);
const [activeTab, setActiveTab] = useState<"capability" | "flow">(() => {
// 根据 URL 参数智能判断初始 tab选了人 → 个人能力图谱;没选人但选了设备 → 设备流转对比
const hasAssignee = (searchParams.get("assignee_id") || "").split(",").filter(Boolean).length > 0;
const hasDevice = (searchParams.get("product_sns") || searchParams.get("product_sn") || searchParams.get("sn") || "")
.split(",").filter(Boolean).length > 0;
return !hasAssignee && hasDevice ? "flow" : "capability";
});
// ─── 下拉选项 + 数据 ─────────────────────────────────────
const [options, setOptions] = useState<AnalyticsOptions>({ assignees: [], spec_models: [], devices: [] });
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 (productSns.length) params.set("product_sns", productSns.join(","));
setSearchParams(params, { replace: true });
}, [assigneeIds, specModels, productSns, 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 = {};
if (productSns.length) q.product_sns = productSns;
if (specModels.length) q.spec_models = specModels;
return q;
}, [productSns, 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 && productSns.length === 0) {
setFlow(null);
return;
}
setFlowLoading(true);
try {
setFlow(await fetchFlowData(buildFlowQuery()));
} catch {
setError("加载数据失败,请确认后端已启动");
} finally {
setFlowLoading(false);
}
}, [specModels, productSns, 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([]);
setProductSns([]);
};
// ─── 柱状图人员视图X=设备身份证,系列=人员,居中紧凑,消除幽灵占位) ──
const capabilityOption = useMemo<EChartsCoreOption>(() => {
const categories = capability?.categories ?? [];
const devices = capability?.devices ?? [];
// 每台设备上真正产生耗时的人员 seriesIndex 数组
const activePerDevice = devices.map((_, devIdx) =>
(capability?.series ?? [])
.map((s, sIdx) => (s.data[devIdx] && s.data[devIdx].value != null ? sIdx : -1))
.filter((idx) => idx !== -1),
);
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
appendToBody: true,
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.actualValue ?? 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: 60, bottom: 96 },
xAxis: {
type: "category",
data: devices.map((d) =>
[d.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"),
),
axisLabel: { fontSize: 10, interval: 0, lineHeight: 13 },
},
yAxis: { type: "value", name: "耗时(小时)" },
series: (capability?.series ?? []).map((s, sIdx) => ({
name: s.name,
type: "custom",
encode: { x: 0, y: 1 },
label: {
show: true,
position: "top",
distance: 6,
formatter: (p: any) => (p.data?.actualValue == null ? "" : `${p.data.actualValue}h`),
},
renderItem: (params: any, api: any) => {
const devIdx = params.dataIndex;
const val = api.value(1);
if (val == null || isNaN(val)) return;
const activeSeries = activePerDevice[devIdx];
const localIndex = activeSeries.indexOf(sIdx);
if (localIndex === -1) return;
const barWidth = 24; // 黄金粗细,绝不妥协
const gap = 6; // 紧凑的柱间距
const totalWidth = activeSeries.length * barWidth + (activeSeries.length - 1) * gap;
// 核心:彻底消除幽灵占位,让存活的柱子绝对居中对齐
const centerX = api.coord([devIdx, 0])[0];
const x = centerX - totalWidth / 2 + localIndex * (barWidth + gap);
const valY = api.coord([devIdx, val])[1];
const y0 = api.coord([devIdx, 0])[1];
const height = Math.max(y0 - valY, 3); // 至少 3px0 值/极小值也有柱子
const y = y0 - height;
return {
type: "rect",
shape: { x, y, width: barWidth, height, r: [3, 3, 0, 0] },
style: api.style(),
};
},
data: s.data.map((d, i) => {
if (d.value == null) return null;
return {
...d,
value: [i, d.value], // custom 必须的 [x, y] 坐标格式
actualValue: d.value, // 供 tooltip 读取的真实值
};
}),
})),
};
}, [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 ?? [];
// 分支配色(与人员一一对应,用于柱子和 tooltip 色点)
const BRANCH_COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16"];
const colorMap: Record<string, string> = {};
series.forEach((s, index) => {
colorMap[s.name] = BRANCH_COLORS[index % BRANCH_COLORS.length];
});
// 1. 预计算每台设备的统计:累计投入、空闲时长、人员排序
const deviceStats = devices.map((d, idx) => {
let totalInputHours = 0;
const personHours: Record<string, number> = {};
const intervals: [number, number][] = [];
series.forEach((s) => {
s.data.forEach((task: any) => {
if (task[0] === idx) {
totalInputHours += task[4];
personHours[s.name] = (personHours[s.name] || 0) + task[4];
intervals.push([task[1], task[2]]);
}
});
});
// 合并重叠区间,计算空闲时长(生命周期内未被任务覆盖的部分)
intervals.sort((a, b) => a[0] - b[0]);
let covered = 0;
let mergedEnd = -Infinity;
for (const [s, e] of intervals) {
if (s > mergedEnd) {
covered += e - s;
mergedEnd = e;
} else if (e > mergedEnd) {
covered += e - mergedEnd;
mergedEnd = e;
}
}
const idleTime = Math.max((d.lead_time || 0) - covered, 0);
const sortedPeople = Object.entries(personHours).sort((a, b) => b[1] - a[1]);
return {
leadTime: d.lead_time || 0,
totalInputHours,
idleTime,
sortedPeople,
};
});
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow", shadowStyle: { color: "rgba(0,0,0,0.05)" } },
formatter: (params: any) => {
const deviceIndex = params[0].dataIndex;
const device = devices[deviceIndex];
const stats = deviceStats[deviceIndex];
if (!device || !stats) return "";
let html = `<div style="font-size:14px;font-weight:bold;color:#1f2937;margin-bottom:4px;">${device.spec_model || "未知型号"}</div>`;
html += `<div style="color:#6b7280;font-size:12px;margin-bottom:8px;">身份证: ${device.product_sn}</div>`;
html += `<hr style="margin:8px 0;border-color:#e5e7eb" />`;
if (device.started_at) {
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>开始时间:</span><b style="color:#111827">${device.started_at}</b></div>`;
}
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>实际流转周期:</span><b style="color:#111827">${stats.leadTime} h</b></div>`;
html += `<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span>累计投入工时:</span><b style="color:#2563eb">${stats.totalInputHours.toFixed(1)} h</b></div>`;
html += `<div style="display:flex;justify-content:space-between;margin-bottom:12px;"><span>中间空闲时长:</span><b style="color:#f59e0b">${stats.idleTime.toFixed(1)} h</b></div>`;
if (stats.sortedPeople.length > 0) {
html += `<div style="font-size:12px;color:#9ca3af;margin-bottom:6px;">人员耗时占比分析:</div>`;
stats.sortedPeople.forEach(([name, hours]) => {
const percentage = stats.totalInputHours > 0 ? ((hours / stats.totalInputHours) * 100).toFixed(1) : "0.0";
const color = colorMap[name] || "#9CA3AF";
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="display:flex;align-items:center;color:#4b5563;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:6px;flex-shrink:0;"></span>${name}</span>
<span style="margin-left:24px;color:#4b5563;">${hours.toFixed(1)}h (${percentage}%)</span>
</div>`;
});
}
return html;
},
},
legend: { top: 0, type: "scroll", data: series.map((s) => s.name) },
grid: { left: 48, right: 24, top: 60, bottom: 96 },
xAxis: {
type: "category",
data: devices.map((d) =>
[d.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"),
),
axisLabel: { fontSize: 11, interval: 0, color: '#666', lineHeight: 14 },
axisLine: { lineStyle: { color: '#ddd' } },
axisTick: { show: false }
},
yAxis: {
type: "value",
name: "设备生命周期 (小时)",
min: 0,
nameTextStyle: { color: '#999', padding: [0, 0, 0, 20] },
splitLine: { lineStyle: { type: 'dashed', color: '#f3f4f6' } }
},
series: [
// 1. 背景主干 (The Trunk) - 连贯的浅灰底
{
name: "总生命周期",
type: "bar" as const,
barMaxWidth: 32,
z: 1,
itemStyle: { color: "transparent" },
label: {
show: true,
position: "top",
formatter: (p: any) => `{title|总计}\n{value|${p.value}h}`,
rich: {
title: { fontSize: 12, color: "#999", padding: [0, 0, 2, 0] },
value: { fontSize: 14, fontWeight: "bold", color: "#1f2937" },
},
},
data: devices.map((d) => d.lead_time || 0),
},
// 2. 任务分支 (The Branches) - 贴主干两侧
...series.map((s, index) => ({
name: s.name,
type: "custom" as const,
z: 2,
itemStyle: { color: BRANCH_COLORS[index % BRANCH_COLORS.length] },
dimensions: ['device', 'start', 'end', 'task', 'duration', 'is_main'],
encode: { x: 0, y: [1, 2] },
renderItem: (params: any, api: any) => {
const categoryIndex = api.value(0);
const rawData = s.data[params.dataIndex]; // [device, start, end, task, duration, is_main]
const start = api.coord([categoryIndex, rawData[1]]);
const end = api.coord([categoryIndex, rawData[2]]);
const isMain = rawData[5] === 1; // 解析后端传来的主次标识
const trunkWidth = 32; // 灰色背景主干的宽度
const blockWidth = 20;
let x;
if (isMain) {
// 【主线任务】:绝对居中!盖在灰色主干的正中心
x = start[0] - blockWidth / 2;
} else {
// 【分支任务】:悬挂在主干的右侧(+2px 缝隙避免粘连)
x = start[0] + (trunkWidth / 2) + 2;
}
return {
type: "rect",
shape: {
x: x,
y: end[1], // ECharts y轴倒置
width: blockWidth,
height: Math.max(start[1] - end[1], 2), // 至少 2px 高度
r: 2,
},
style: {
fill: BRANCH_COLORS[index % BRANCH_COLORS.length],
opacity: isMain ? 0.9 : 0.75, // 主线颜色更实,分支略透明
},
};
},
data: s.data,
})),
],
};
}, [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: alignName(a.name) }))}
style={{ minWidth: 160, maxWidth: 240 }}
maxTagCount="responsive"
popupMatchSelectWidth={false}
dropdownRender={() => (
<div className="w-[400px] p-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-gray-400"> {options.assignees.length} </span>
<div className="flex items-center gap-1">
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setAssigneeIds(options.assignees.map((a) => a.id)); }}></Button>
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setAssigneeIds([]); }}></Button>
</div>
</div>
<div className="grid grid-cols-4 gap-2">
{options.assignees.map((a) => {
const checked = assigneeIds.includes(a.id);
return (
<div
key={a.id}
onClick={() => setAssigneeIds((prev) => (checked ? prev.filter((id) => id !== a.id) : [...prev, a.id]))}
className={`cursor-pointer rounded-md px-2 py-1.5 text-center text-sm transition-colors ${checked ? "bg-blue-50 font-medium text-blue-700" : "bg-gray-50 text-gray-600 hover:bg-gray-100"}`}
>
{alignName(a.name)}
</div>
);
})}
</div>
</div>
)}
/>
<Select
mode="multiple"
size="small"
allowClear
placeholder="规格型号"
value={specModels}
onChange={setSpecModels}
options={options.spec_models.map((s) => ({
value: s.spec_model,
label: s.material_name ? `${s.material_name} (${s.spec_model})` : s.spec_model,
}))}
style={{ minWidth: 160, maxWidth: 240 }}
maxTagCount="responsive"
popupMatchSelectWidth={false}
dropdownRender={() => (
<div className="w-[460px] p-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-gray-400"> {options.spec_models.length} </span>
<div className="flex items-center gap-1">
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setSpecModels(options.spec_models.map((s) => s.spec_model)); }}></Button>
<Button size="small" type="link" onClick={(e) => { e.stopPropagation(); setSpecModels([]); }}></Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{options.spec_models.map((s) => {
const checked = specModels.includes(s.spec_model);
return (
<div
key={s.spec_model}
onClick={() => setSpecModels((prev) => (checked ? prev.filter((m) => m !== s.spec_model) : [...prev, s.spec_model]))}
className={`cursor-pointer rounded-md px-2 py-1.5 transition-colors ${checked ? "bg-blue-50 font-medium text-blue-700" : "bg-gray-50 text-gray-600 hover:bg-gray-100"}`}
>
<div className="truncate text-sm">{s.material_name || "未知产品"}</div>
<div className="truncate text-xs text-gray-400">{s.spec_model}</div>
</div>
);
})}
</div>
</div>
)}
/>
<Select
mode="multiple"
showSearch
allowClear
size="small"
placeholder="搜索或选择设备..."
value={productSns}
onChange={setProductSns}
popupMatchSelectWidth={false}
style={{ minWidth: 200 }}
styles={{ popup: { root: { minWidth: 440 } } }}
options={options.devices.map((d) => ({
value: d.product_sn,
label: d.external_serial ? `${d.external_serial} (${d.product_sn})` : d.product_sn,
data: d,
}))}
optionRender={(oriOption: any) => {
const d = oriOption.data?.data;
if (!d) return oriOption.label;
return (
<div className="flex flex-col border-b border-gray-50 py-2">
<div className="text-sm font-bold text-gray-800">
{d.material_name || "未知产品"}{" "}
<span className="text-xs font-normal text-gray-500">({d.spec_model || "无型号"})</span>
</div>
<div className="mt-1 flex items-center justify-between text-xs">
<span className="text-gray-400">
S/N: <span className="text-gray-600">{d.external_serial || "无"}</span>
</span>
<span className="font-mono text-xs text-gray-500">ID: {d.product_sn}</span>
</div>
</div>
);
}}
/>
<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>
) : (
<div className="w-full overflow-x-auto pb-2 custom-scrollbar">
<div style={{ minWidth: (capability?.devices?.length ?? 0) * 140 }}>
<BaseEChart
option={capabilityOption}
height={420}
onZrClick={handleZrClick}
/>
</div>
</div>
)}
</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 && productSns.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>
);
}

View File

@ -0,0 +1,141 @@
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;
material_name: 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;
lead_time: number; // 设备生命周期总时长(小时)
started_at: string; // 设备最早介入时间T0MM-DD HH:mm
}
// 时间区间deviceIndex, startOffset, endOffset, taskName, duration, isMain
export type FlowInterval = [number, number, number, string, number, number];
export interface FlowSeries {
name: string; // 人员姓名
data: FlowInterval[]; // 该人员的任务时间区间列表(同一设备可多次出现)
}
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 SpecModelOption {
spec_model: string;
material_name: string;
}
export interface DeviceOption {
product_sn: string;
external_serial: string | null;
material_name: string;
spec_model: string;
}
export interface AnalyticsOptions {
assignees: AssigneeOption[];
spec_models: SpecModelOption[];
devices: DeviceOption[];
}
// ─── 查询参数 ──────────────────────────────────────────────
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;
}