diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py
index 76246f5..a38adbf 100644
--- a/backend/app/api/v1/endpoints/dashboard.py
+++ b/backend/app/api/v1/endpoints/dashboard.py
@@ -6,6 +6,8 @@ from app.core.database import get_db
from app.services.dashboard_service import (
get_dashboard_stats, DashboardStats,
get_wip_tasks, WipTask,
+ get_completed_tasks, CompletedTask,
+ get_people_workload, PersonWorkload,
search_product_messages, ProductMessageList,
)
@@ -38,6 +40,27 @@ async def wip_tasks(
return await get_wip_tasks(db, limit)
+@router.get("/completed-tasks", response_model=list[CompletedTask])
+async def completed_tasks(
+ since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
+ until: str | None = Query(None, description="截止日期 ISO"),
+ limit: int = Query(200, ge=1, le=500),
+ db: AsyncSession = Depends(get_db),
+):
+ """流转完成率下钻 — 按时段查询已完成任务明细"""
+ since_dt = datetime.fromisoformat(since) if since else None
+ until_dt = datetime.fromisoformat(until) if until else None
+ return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit)
+
+
+@router.get("/people-workload", response_model=list[PersonWorkload])
+async def people_workload(
+ db: AsyncSession = Depends(get_db),
+):
+ """人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)"""
+ return await get_people_workload(db)
+
+
@router.get("/messages", response_model=ProductMessageList)
async def dashboard_messages(
keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"),
diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py
index 872ff81..5773f40 100644
--- a/backend/app/services/dashboard_service.py
+++ b/backend/app/services/dashboard_service.py
@@ -37,6 +37,33 @@ class WipTask(BaseModel):
duration_hours: float
+class CompletedTask(BaseModel):
+ task_id: str
+ task_name: str # 完成的工序节点
+ assignee: str # 完成人中文姓名
+ product_sn: str # 16位HEX身份证
+ external_serial: str | None # 业务序列号
+ material_name: str # 产品名称(物料名称)
+ spec_model: str # 规格型号
+ completed_at: str # 完成时间 ISO
+
+
+class PersonDevice(BaseModel):
+ product_id: str
+ serial_number: str # 16位HEX身份证
+ external_serial: str | None # 业务序列号
+ material_name: str # 产品名称
+ spec_model: str # 规格型号
+ task_status: str # 该设备名下的状态 WIP/PENDING
+
+
+class PersonWorkload(BaseModel):
+ assignee_id: str
+ assignee_name: str # 中文姓名
+ device_count: int
+ devices: list[PersonDevice]
+
+
class ProductMessageItem(BaseModel):
id: str
content: str
@@ -180,6 +207,129 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
return wip_list[:limit]
+# ============================================================
+# 已完成任务明细(流转完成率下钻 — 按时段过滤)
+# ============================================================
+
+async def get_completed_tasks(
+ db: AsyncSession,
+ since: datetime | None = None,
+ until: datetime | None = None,
+ limit: int = 200,
+) -> list[CompletedTask]:
+ """按时段查询已完成任务明细(上帝视角),用于「流转完成率」卡片下钻。"""
+ from app.models.task import Task, TASK_STATUS_COMPLETED
+ from app.models.product import Product
+ from app.core.time_utils import BEIJING_TZ
+
+ stmt = (
+ select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
+ .join(Product, Task.product_id == Product.id)
+ .where(Task.status == TASK_STATUS_COMPLETED)
+ )
+ if since:
+ stmt = stmt.where(Task.completed_at >= since)
+ if until:
+ stmt = stmt.where(Task.completed_at <= until)
+ stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
+ result = await db.execute(stmt)
+ rows = result.all()
+
+ raw_ids = list({t.assignee_id for t, *_ in rows if t.assignee_id})
+ name_map: dict[str, str] = {}
+ if raw_ids:
+ from app.services.mom_cache import get_display_names
+ name_map = get_display_names(raw_ids)
+
+ items: list[CompletedTask] = []
+ for task, sn, ext, mat, spec in rows:
+ t = task.completed_at
+ if t:
+ if t.tzinfo is None:
+ from datetime import timezone as dt_timezone
+ t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
+ else:
+ t = t.astimezone(BEIJING_TZ)
+ time_str = t.isoformat() if t else ""
+ items.append(CompletedTask(
+ task_id=str(task.id),
+ task_name=task.task_name,
+ assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"),
+ product_sn=sn or "",
+ external_serial=ext or None,
+ material_name=mat or "",
+ spec_model=spec or "",
+ completed_at=time_str,
+ ))
+ return items
+
+
+# ============================================================
+# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
+# ============================================================
+
+async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
+ """上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重)。"""
+ from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
+ from app.models.product import Product
+
+ stmt = (
+ select(
+ Task.assignee_id,
+ Product.id, Product.serial_number, Product.external_serial,
+ Product.material_name, Product.spec_model,
+ Task.status,
+ )
+ .join(Product, Task.product_id == Product.id)
+ .where(
+ Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
+ Task.assignee_id.isnot(None),
+ )
+ .order_by(Task.assignee_id, Product.created_at.desc())
+ )
+ result = await db.execute(stmt)
+ rows = result.all()
+
+ # 按 assignee 聚合,product 去重;状态优先级 WIP > PENDING
+ by_assignee: dict[str, dict[str, PersonDevice]] = {}
+ for row in rows:
+ assignee = row[0]
+ product_id = str(row[1])
+ status = row[6] or ""
+ devices = by_assignee.setdefault(assignee, {})
+ if product_id in devices:
+ # 已有该设备:若新状态为 WIP 则提升(更活跃)
+ if status == "WIP":
+ devices[product_id].task_status = "WIP"
+ continue
+ devices[product_id] = PersonDevice(
+ product_id=product_id,
+ serial_number=row[2] or "",
+ external_serial=row[3] or None,
+ material_name=row[4] or "",
+ spec_model=row[5] or "",
+ task_status=status,
+ )
+
+ 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)
+
+ workloads = [
+ PersonWorkload(
+ assignee_id=assignee,
+ assignee_name=name_map.get(assignee, assignee),
+ device_count=len(devices),
+ devices=list(devices.values()),
+ )
+ for assignee, devices in by_assignee.items()
+ ]
+ workloads.sort(key=lambda w: w.device_count, reverse=True)
+ return workloads
+
+
# ============================================================
# 协同留言搜索(上帝视角 — 全厂)
# ============================================================
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 696bbe3..57f4a9e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -20,6 +20,7 @@ const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage"));
const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard"));
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"));
export default function App() {
@@ -48,6 +49,7 @@ export default function App() {
按负责人聚合当前在制品设备 · 实时快照
+{workloads.length} 人
+持有在制品的负责人数
+{totalDevices} 台
+一台设备若被多人并发处理,会在多人名下各计一次
+无设备
+ ) : ( +