diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 7ec55fc..ddef22e 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -1,11 +1,10 @@ """Dashboard API""" -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.dashboard_service import ( get_dashboard_stats, DashboardStats, - get_recent_activity, RecentActivity, + get_wip_tasks, WipTask, ) router = APIRouter(prefix="/dashboard", tags=["管理看板"]) @@ -16,14 +15,10 @@ async def dashboard_stats(db: AsyncSession = Depends(get_db)): return await get_dashboard_stats(db) -@router.get("/recent-activity", response_model=list[RecentActivity]) -async def recent_activity( - limit: int = Query(10, ge=1, le=50), - since: str | None = Query(None, description="起始日期 ISO格式 如 2026-08-12T00:00:00"), - until: str | None = Query(None, description="截止日期 ISO格式"), +@router.get("/wip-tasks", response_model=list[WipTask]) +async def wip_tasks( + limit: int = Query(20, ge=1, le=100), 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_recent_activity(db, limit, since=since_dt, until=until_dt) + """在制品看板 — 当前所有 PENDING/WIP 任务,按滞留时间排序""" + return await get_wip_tasks(db, limit) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 6fe78fa..e9d7cd8 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -18,26 +18,30 @@ class DashboardStats(BaseModel): tasks_completed: int tasks_rejected: int tasks_rework: int - # 通知 + # 通知 + 留言 unread_notifications: int = 0 + unread_messages: int = 0 -class RecentActivity(BaseModel): - action: str +class WipTask(BaseModel): + """在制品 — 当前卡在手里的任务""" + task_id: str task_name: str - operator: str + assignee: str # 中文姓名 product_sn: str - time: str - remark: str | None = None + status: str # PENDING / WIP + received_at: str # 接收时间 + duration_hours: float # 已滞留小时数 async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: from app.models.product import Product from app.models.task import ( Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, - TASK_STATUS_REJECTED, TASK_STATUS_ARCHIVED, + TASK_STATUS_REJECTED, ) from app.models.notification import Notification + from app.models.message import ProductMessage p_total = await db.scalar(select(func.count(Product.id))) p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending")) @@ -51,9 +55,11 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: t_rejected = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED)) t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True))) - unread = await db.scalar( + unread_notif = await db.scalar( select(func.count(Notification.id)).where(Notification.is_read.is_(False)) ) + # 留言板未读数 — 全局(所有产品下的留言总数) + unread_msg = await db.scalar(select(func.count(ProductMessage.id))) return DashboardStats( products_total=p_total or 0, @@ -66,80 +72,71 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: tasks_completed=t_done or 0, tasks_rejected=t_rejected or 0, tasks_rework=t_rework or 0, - unread_notifications=unread or 0, + unread_notifications=unread_notif or 0, + unread_messages=unread_msg or 0, ) -async def get_recent_activity( - db: AsyncSession, limit: int = 10, - since: datetime | None = None, until: datetime | None = None, -) -> list[RecentActivity]: - from app.models.task_log import TaskLog - from app.models.task import Task +async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]: + """ + 在制品看板 — 当前所有 PENDING/WIP 状态的任务, + 按接收时间升序(最早接收的排最前 = 滞留最久)。 + """ + from app.models.task import ( + Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, + ) from app.models.product import Product - from app.core.time_utils import BEIJING_TZ + from app.core.time_utils import get_beijing_time, BEIJING_TZ stmt = ( - select(TaskLog, Task.task_name, Product.serial_number, Task.assignee_id) - .join(Task, TaskLog.task_id == Task.id) + select(Task, Product.serial_number) .join(Product, Task.product_id == Product.id) + .where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP])) + .order_by(Task.received_at.asc().nulls_first(), Task.created_at.asc()) + .limit(limit) ) - if since: - stmt = stmt.where(TaskLog.created_at >= since) - if until: - stmt = stmt.where(TaskLog.created_at <= until) - stmt = stmt.order_by(TaskLog.created_at.desc()).limit(limit) result = await db.execute(stmt) rows = result.all() - # 收集 operator_id + 兜底 assignee_id → 批量翻译中文姓名 - raw_ids: set[str] = set() - for row in rows: - op = row[0].operator_id - if op: - raw_ids.add(op) - elif row[3]: # assignee_id 兜底 - raw_ids.add(row[3]) + # 收集 assignee_id 批量翻译中文姓名 + 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(list(raw_ids)) + name_map = get_display_names(raw_ids) - activities: list[RecentActivity] = [] - for log, task_name, product_sn, task_assignee in rows: - action_label = _action_label(log.action_type) - # 强制转北京时间显示 - t = log.created_at - if t: - if t.tzinfo is None: - t = t.replace(tzinfo=BEIJING_TZ) + now = get_beijing_time() + wip_list: list[WipTask] = [] + for task, product_sn in rows: + # 计算滞留时长 + start = task.received_at or task.created_at + if start: + if start.tzinfo is None: + start = start.replace(tzinfo=BEIJING_TZ) else: - t = t.astimezone(BEIJING_TZ) - time_str = t.strftime("%m-%d %H:%M") + start = start.astimezone(BEIJING_TZ) + hours = round((now - start).total_seconds() / 3600, 1) + recv_str = start.strftime("%m-%d %H:%M") else: - time_str = "" - # operator_id: 优先显示中文姓名 → 英文用户名兜底 → 任务负责人兜底 → 空 - op = log.operator_id or task_assignee or "" - op_display = name_map.get(op, op) - activities.append(RecentActivity( - action=action_label, - task_name=task_name or "", - operator=op_display, + hours = 0 + recv_str = "" + + wip_list.append(WipTask( + task_id=str(task.id), + task_name=task.task_name, + assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"), product_sn=product_sn or "", - time=time_str, - remark=log.remark, + status=task.status, + received_at=recv_str, + duration_hours=hours, )) - return activities + return wip_list def _action_label(action_type: str) -> str: labels = { - "create": "创建任务", - "receive": "确认接收", - "complete": "完成任务", - "transfer": "完工转交", - "reject": "品质驳回", - "end": "结束分支", + "create": "创建任务", "receive": "确认接收", "complete": "完成任务", + "transfer": "完工转交", "reject": "品质驳回", "end": "结束分支", "recall": "撤回转交", } return labels.get(action_type, action_type) diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 4172808..7f1304e 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -1,32 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { Package, ClipboardList, Bell, TrendingUp, AlertTriangle, - RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Calendar, + RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Clock, MessageCircle, } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { - fetchDashboardStats, fetchRecentActivity, - type DashboardStats, type RecentActivity, + fetchDashboardStats, fetchWipTasks, + type DashboardStats, type WipTask, } from "../../services/dashboardApi"; -// ─── 日期筛选选项 ───────────────────────────────────────── -type DateRange = "today" | "7d" | "30d" | "all"; -const DATE_OPTIONS: { key: DateRange; label: string }[] = [ - { key: "today", label: "今天" }, - { key: "7d", label: "近7天" }, - { key: "30d", label: "近30天" }, - { key: "all", label: "全部" }, -]; - -function getSince(key: DateRange): string | undefined { - if (key === "all") return undefined; - const now = new Date(); - now.setHours(0, 0, 0, 0); - if (key === "today") return now.toISOString(); - now.setDate(now.getDate() - (key === "7d" ? 7 : 30)); - return now.toISOString(); -} - // ─── 进度条 ─────────────────────────────────────────────── function ProgressBar({ a, b, c, total, labels }: { a: number; b: number; c: number; total: number; @@ -39,7 +21,6 @@ function ProgressBar({ a, b, c, total, labels }: { { n: b, color: "bg-blue-500", label: labels[1] }, { n: c, color: "bg-emerald-500", label: labels[2] }, ].filter(s => s.n > 0); - return (
产品 = 物理实体(身份证)| 任务 = 工序节点(流转步骤)
{stats.products_total} 个
-已登记的物理产品数量
+已登记产品 · 流转中/已完成
{stats.tasks_total} 个
-产品下所有工序任务汇总
+工序汇总 · 1产品=N任务
{stats.tasks_rejected + stats.tasks_rework}
+{stats.tasks_rejected + stats.tasks_rework}
驳回/返工
已驳回
-返工中
-未读消息
+
- 💡 一个产品 = 多道工序
- 产品从创建到入库,经过装配→接线→质检等多道工序。
- 每个工序就是一个任务,
- 由不同工人完成。任务数 ≥ 产品数是正常的。
+ 💡 在制品 = 当前卡在工人手里的活
+ 颜色越红 = 滞留越久。超过
+ 48小时需关注,
+ 24小时内属正常。