From 54b5ea00f119fe89095051148b4e5ad68ca6d279 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 14 Aug 2026 10:19:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=95=88=E8=83=BD?= =?UTF-8?q?=E5=88=86=E6=9E=90=E7=9C=8B=E6=9D=BF=EF=BC=88=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=9B=BE=E8=B0=B1=20+=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E8=80=97=E6=97=B6=E5=AF=B9=E6=AF=94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端:AnalyticsDashboard 页面 + BaseEChart 封装 + analyticsApi,路由与导航 - 后端:/analytics 系列接口(capability/flow/options/device-records) - 能力图谱单机颗粒度、流转对比按人堆叠识别瓶颈、点击下钻备注、筛选动态联动 - 依赖:引入 echarts,移除 echarts-for-react --- backend/app/api/v1/endpoints/analytics.py | 69 +++ backend/app/api/v1/router.py | 2 + backend/app/services/analytics_service.py | 501 +++++++++++++++ frontend/package-lock.json | 26 + frontend/package.json | 1 + frontend/src/App.tsx | 2 + frontend/src/components/BaseEChart.tsx | 80 +++ .../src/components/layout/AdminLayout.tsx | 8 +- .../src/pages/admin/AnalyticsDashboard.tsx | 580 ++++++++++++++++++ frontend/src/services/analyticsApi.ts | 122 ++++ 10 files changed, 1390 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/endpoints/analytics.py create mode 100644 backend/app/services/analytics_service.py create mode 100644 frontend/src/components/BaseEChart.tsx create mode 100644 frontend/src/pages/admin/AnalyticsDashboard.tsx create mode 100644 frontend/src/services/analyticsApi.ts diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py new file mode 100644 index 0000000..0fa50d9 --- /dev/null +++ b/backend/app/api/v1/endpoints/analytics.py @@ -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) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 483be7f..30b9a19 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py new file mode 100644 index 0000000..9721855 --- /dev/null +++ b/backend/app/services/analytics_service.py @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9be1fc5..f53927a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 6c32e25..de034a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 328c92f..16d89a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/components/BaseEChart.tsx b/frontend/src/components/BaseEChart.tsx new file mode 100644 index 0000000..edaf902 --- /dev/null +++ b/frontend/src/components/BaseEChart.tsx @@ -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; + +interface BaseEChartProps { + option: EChartsCoreOption; + height?: number | string; + className?: string; + onEvents?: Record void>; + onZrClick?: (chart: EChartsInstance, event: any) => void; +} + +export default function BaseEChart({ option, height = 380, className, onEvents, onZrClick }: BaseEChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(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
; +} diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 0ccfdbc..fb3ff8d 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; -import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users } 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() { diff --git a/frontend/src/pages/admin/AnalyticsDashboard.tsx b/frontend/src/pages/admin/AnalyticsDashboard.tsx new file mode 100644 index 0000000..1823b19 --- /dev/null +++ b/frontend/src/pages/admin/AnalyticsDashboard.tsx @@ -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 = { + WIP: "进行中", + PENDING: "待接收", + COMPLETED: "已完成", + "—": "未涉及", +}; + +const STATUS_BADGE: Record = { + 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 = { + 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(() => + (searchParams.get("assignee_id") || "").split(",").filter(Boolean), + ); + const [specModels, setSpecModels] = useState(() => + (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({ assignees: [], spec_models: [] }); + const [capability, setCapability] = useState(null); + const [capabilityLoading, setCapabilityLoading] = useState(false); + const [flow, setFlow] = useState(null); + const [flowLoading, setFlowLoading] = useState(false); + const [error, setError] = useState(null); + + // ─── 弹窗下钻:设备备注(all=true 表示流转图点击,展示全部) ── + const [recordQuery, setRecordQuery] = useState<{ sn: string; all: boolean } | null>(null); + const [records, setRecords] = useState([]); + 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([]); + categoriesRef.current = capability?.categories ?? []; + const flowSnRef = useRef([]); + 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(() => { + 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 + ? `
接收时间:${dayjs(d.first_received_at).format("MM-DD HH:mm")}` + : ""; + const done = d.last_completed_at + ? `
结束时间:${dayjs(d.last_completed_at).format("MM-DD HH:mm")}` + : ""; + return `${it.marker}${it.seriesName}:耗时 ${d.value ?? 0} 小时(${st})${recv}${done}`; + }) + .join("
"); + return ( + `设备身份证:${sn}` + + (ext ? `
产品序列号:${ext}` : "") + + `
规格型号:${dev?.spec_model ?? "—"}
${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(); + 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(() => { + 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 || "—"}
身份证:${d.product_sn}
` + : ""; + 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("
"); + 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 ( +
+ {/* 页头 */} +
+
+

📊 效能分析看板

+

个人能力图谱 / 设备流转对比 · ECharts 可视化

+
+
+ + {/* 顶部统一筛选栏 */} +
+
+ 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 ( +
+ {day.date()} + {hasDot && } +
+ ); + }} + style={{ width: 240 }} + placeholder={["开始日期", "结束日期"]} + /> + ({ value: s, label: s }))} + style={{ minWidth: 160, maxWidth: 240 }} + maxTagCount={1} + maxTagTextLength={8} + /> + setProductSn(e.target.value)} + style={{ width: 200 }} + /> + + +
+
+ + {/* 错误提示 */} + {error && ( +
+ + {error} +
+ )} + + {/* 主体:两个可视化区块 */} + + + 个人能力图谱 + + ), + children: ( +
+

个人能力图谱(单台设备耗时 · 点击柱子查看备注)

+ {capabilityLoading && !capability ? ( +
+ +
+ ) : capabilityEmpty ? ( +
+ +
+ ) : ( + + )} +
+ ), + }, + { + key: "flow", + label: ( + + + 设备流转对比 + + ), + children: ( +
+

设备人员耗时对比 (按人堆叠)

+ {flowLoading && !flow ? ( +
+ +
+ ) : flowEmpty ? ( +
+ +
+ ) : ( + + )} +
+ ), + }, + ]} + /> + + {/* 设备备注弹窗 */} + setRecordQuery(null)} + footer={null} + width={640} + > + {recordsLoading ? ( +
+ +
+ ) : records.length === 0 ? ( +

暂无备注记录

+ ) : ( + ({ + color: STATUS_DOT[r.status] ?? "gray", + children: ( +
+ {/* 头部:工序名 · 操作人 + 状态徽章 + 时间 */} +
+ {r.task_name || "—"} + · {r.assignee_name || "—"} + + {STATUS_LABEL[r.status] ?? r.status} + + + {r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""} + +
+ + {/* 备注文本 */} + {r.remark && ( +
{r.remark}
+ )} + + {/* 图片:PreviewGroup 包裹,点击全屏放大预览 */} + {r.images && r.images.length > 0 && ( + +
+ {r.images.map((img, j) => ( + {`备注图片 + ))} +
+
+ )} +
+ ), + }))} + /> + )} +
+
+ ); +} diff --git a/frontend/src/services/analyticsApi.ts b/frontend/src/services/analyticsApi.ts new file mode 100644 index 0000000..6009885 --- /dev/null +++ b/frontend/src/services/analyticsApi.ts @@ -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 { + const params: Record = {}; + 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("/analytics/capability", { params }); + return data; +} + +export async function fetchFlowData(query: FlowQuery): Promise { + const params: Record = {}; + 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("/analytics/flow", { params }); + return data; +} + +export async function fetchDeviceRecords(product_sn: string, assigneeIds?: string[]): Promise { + const params: Record = { product_sn }; + if (assigneeIds?.length) params.assignee_ids = assigneeIds.join(","); + const { data } = await api.get("/analytics/device-records", { params }); + return data; +} + +export async function fetchAnalyticsOptions(assigneeIds?: string[], specModels?: string[]): Promise { + const params: Record = {}; + if (assigneeIds?.length) params.assignee_ids = assigneeIds.join(","); + if (specModels?.length) params.spec_models = specModels.join(","); + const { data } = await api.get("/analytics/options", { params }); + return data; +}