diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index ddef22e..76246f5 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -1,18 +1,32 @@ -"""Dashboard API""" +"""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_wip_tasks, WipTask, + search_product_messages, ProductMessageList, ) router = APIRouter(prefix="/dashboard", tags=["管理看板"]) @router.get("/stats", response_model=DashboardStats) -async def dashboard_stats(db: AsyncSession = Depends(get_db)): - return await get_dashboard_stats(db) +async def dashboard_stats( + since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """ + 全局统计(上帝视角)。 + + 时间筛选仅影响 COMPLETED / REJECTED 计数; + PENDING / WIP / 总数永远返回实时快照。 + """ + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_dashboard_stats(db, since=since_dt, until=until_dt) @router.get("/wip-tasks", response_model=list[WipTask]) @@ -20,5 +34,21 @@ async def wip_tasks( limit: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): - """在制品看板 — 当前所有 PENDING/WIP 任务,按滞留时间排序""" + """在制品看板 — 永远实时的 PENDING/WIP 任务""" return await get_wip_tasks(db, limit) + + +@router.get("/messages", response_model=ProductMessageList) +async def dashboard_messages( + keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"), + skip: int = Query(0, ge=0), + limit: int = Query(30, ge=1, le=200), + db: AsyncSession = Depends(get_db), +): + """ + 协同留言搜索(上帝视角 — 全厂所有产品留言)。 + + 关联 Product 表返回 serial_number + material_name, + 按时间倒序排列。 + """ + return await search_product_messages(db, keyword=keyword, skip=skip, limit=limit) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index e9d7cd8..127fdab 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -1,40 +1,69 @@ -"""Dashboard 统计服务""" +"""Dashboard 统计服务 — 上帝视角(全厂全系统数据,不按用户过滤)""" from datetime import datetime -from sqlalchemy import select, func +from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +# ============================================================ +# Schemas +# ============================================================ + class DashboardStats(BaseModel): - # 产品 products_total: int products_pending: int products_in_progress: int products_completed: int - # 任务 tasks_total: int tasks_pending: int tasks_in_progress: int tasks_completed: int tasks_rejected: int tasks_rework: int - # 通知 + 留言 unread_notifications: int = 0 unread_messages: int = 0 class WipTask(BaseModel): - """在制品 — 当前卡在手里的任务""" task_id: str task_name: str - assignee: str # 中文姓名 + assignee: str product_sn: str - status: str # PENDING / WIP - received_at: str # 接收时间 - duration_hours: float # 已滞留小时数 + status: str + received_at: str + duration_hours: float -async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: +class ProductMessageItem(BaseModel): + id: str + content: str + operator_name: str # 留言人中文姓名 + product_sn: str # 16位SN + material_name: str # 物料名称 + created_at: str # ISO时间字符串 + + +class ProductMessageList(BaseModel): + items: list[ProductMessageItem] + total: int + + +# ============================================================ +# 看板统计(时间快照语义) +# ============================================================ + +async def get_dashboard_stats( + db: AsyncSession, + since: datetime | None = None, + until: datetime | None = None, +) -> DashboardStats: + """ + 上帝视角 — 全厂全系统统计。 + + 时间筛选规则: + - PENDING / WIP / 总数:永远忽略时间筛选,返回实时快照。 + - COMPLETED / REJECTED / 完成率:严格按 since~until 过滤(用于时段报表)。 + """ from app.models.product import Product from app.models.task import ( Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, @@ -43,22 +72,34 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: 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")) p_progress = await db.scalar(select(func.count(Product.id)).where(Product.status == "in_progress")) p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed")) + # ── 任务总数 & 实时快照(PENDING/WIP/返工 — 永远不过滤) ── t_total = await db.scalar(select(func.count(Task.id))) t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING)) t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP)) - t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)) - 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))) + # ── 任务已完成/驳回(时间可过滤) ── + t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED) + t_rej_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED) + if since: + t_done_q = t_done_q.where(Task.completed_at >= since) + t_rej_q = t_rej_q.where(Task.completed_at >= since) + if until: + t_done_q = t_done_q.where(Task.completed_at <= until) + t_rej_q = t_rej_q.where(Task.completed_at <= until) + t_done = await db.scalar(t_done_q) + t_rejected = await db.scalar(t_rej_q) + + # ── 通知 & 留言(实时快照) ── 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( @@ -77,14 +118,12 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: ) +# ============================================================ +# 在制品看板(永远实时) +# ============================================================ + 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.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP from app.models.product import Product from app.core.time_utils import get_beijing_time, BEIJING_TZ @@ -98,7 +137,6 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]: result = await db.execute(stmt) rows = result.all() - # 收集 assignee_id 批量翻译中文姓名 raw_ids = list({t.assignee_id for t, _ in rows if t.assignee_id}) name_map: dict[str, str] = {} if raw_ids: @@ -108,7 +146,6 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]: 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: @@ -133,10 +170,73 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]: return wip_list -def _action_label(action_type: str) -> str: - labels = { - "create": "创建任务", "receive": "确认接收", "complete": "完成任务", - "transfer": "完工转交", "reject": "品质驳回", "end": "结束分支", - "recall": "撤回转交", - } - return labels.get(action_type, action_type) +# ============================================================ +# 协同留言搜索(上帝视角 — 全厂) +# ============================================================ + +async def search_product_messages( + db: AsyncSession, + keyword: str = "", + skip: int = 0, + limit: int = 50, +) -> ProductMessageList: + """ + 上帝视角 — 全厂所有产品的协同留言。 + + 关联链: ProductMessage → Product → (material_name, serial_number) + 搜索支持: SN码、物料名称、留言人 + 排序: created_at 倒序(最新在前) + """ + from app.models.message import ProductMessage + from app.models.product import Product + from app.core.time_utils import BEIJING_TZ + + # 基础查询 + stmt = ( + select(ProductMessage, Product.serial_number, Product.material_name) + .join(Product, ProductMessage.product_id == Product.id) + ) + + # 关键词搜索 + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + stmt = stmt.where(or_( + Product.serial_number.ilike(kw), + Product.material_name.ilike(kw), + ProductMessage.operator_id.ilike(kw), + ProductMessage.content.ilike(kw), + )) + + # 总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = await db.scalar(count_stmt) or 0 + + # 分页 + 排序 + stmt = stmt.order_by(ProductMessage.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + rows = result.all() + + # 收集 operator_id → 批量翻译中文姓名 + raw_ids = list({row[0].operator_id for row in rows if row[0].operator_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[ProductMessageItem] = [] + for msg, sn, mat_name in rows: + t = msg.created_at + if t and t.tzinfo is None: + t = t.replace(tzinfo=BEIJING_TZ) + time_str = t.isoformat() if t else "" + + items.append(ProductMessageItem( + id=str(msg.id), + content=msg.content, + operator_name=name_map.get(msg.operator_id, msg.operator_id), + product_sn=sn or "", + material_name=mat_name or "", + created_at=time_str, + )) + + return ProductMessageList(items=items, total=total) diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 7f1304e..8c0b69f 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -1,14 +1,35 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Package, ClipboardList, Bell, TrendingUp, AlertTriangle, - RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, Clock, MessageCircle, + RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, Search, } from "lucide-react"; import { useNavigate } from "react-router-dom"; +import { Radio, DatePicker, Drawer, Input, List, Tag } from "antd"; +import dayjs, { type Dayjs } from "dayjs"; import { - fetchDashboardStats, fetchWipTasks, - type DashboardStats, type WipTask, + fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, + type DashboardStats, type WipTask, type ProductMessageItem, } from "../../services/dashboardApi"; +const { RangePicker } = DatePicker; + +// ─── 时间筛选选项 ───────────────────────────────────────── +type DateRangeKey = "today" | "7d" | "30d" | "custom"; + +function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) { + if (key === "custom" && customRange) { + return { + since: customRange[0].startOf("day").toISOString(), + until: customRange[1].endOf("day").toISOString(), + }; + } + const since = dayjs().startOf("day"); + if (key === "7d") return { since: since.subtract(7, "day").toISOString() }; + if (key === "30d") return { since: since.subtract(30, "day").toISOString() }; + // today + return { since: since.toISOString() }; +} + // ─── 进度条 ─────────────────────────────────────────────── function ProgressBar({ a, b, c, total, labels }: { a: number; b: number; c: number; total: number; @@ -41,32 +62,28 @@ function ProgressBar({ a, b, c, total, labels }: { ); } -// ─── 滞留时间颜色 ───────────────────────────────────────── -function durationColor(h: number): string { +// ─── 滞留时间 ───────────────────────────────────────────── +function durationColor(h: number) { if (h >= 48) return "text-red-600 bg-red-50"; if (h >= 24) return "text-orange-600 bg-orange-50"; if (h >= 8) return "text-amber-600 bg-amber-50"; return "text-gray-500 bg-gray-50"; } - -function durationLabel(h: number): string { +function durationLabel(h: number) { if (h >= 48) return `${Math.round(h / 24)}天`; if (h >= 24) return `${Math.round(h / 24)}天`; if (h >= 1) return `${h}小时`; return `${Math.round(h * 60)}分钟`; } -// ─── 在制品条目 ─────────────────────────────────────────── function WipRow({ t }: { t: WipTask }) { return (
- 产品 = 物理实体(身份证)| 任务 = 工序节点(流转步骤) + 产品 = 物理实体(身份证)| 任务 = 工序节点
{stats.products_total} 个
-已登记产品 · 流转中/已完成
+实时快照(不受时间筛选影响)
{stats.tasks_total} 个
-工序汇总 · 1产品=N任务
+PENDING/WIP 实时 | COMPLETED 按时段
{stats.tasks_rejected + stats.tasks_rework}
驳回/返工
{stats.tasks_total > 0 ? Math.round((stats.tasks_completed / stats.tasks_total) * 100) : 0}%
-{stats.tasks_completed}/{stats.tasks_total} 已完成
+{stats.tasks_completed}/{stats.tasks_total}
基于时间筛选后的已完成数
- 💡 在制品 = 当前卡在工人手里的活
- 颜色越红 = 滞留越久。超过
- 48小时需关注,
- 24小时内属正常。
-