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 (
- {/* 状态标识 */} {t.status === "WIP" ? "进行中" : "待接收"} - {/* 任务信息 */}
{t.task_name} @@ -77,7 +94,6 @@ function WipRow({ t }: { t: WipTask }) { {t.received_at && 接收: {t.received_at}}
- {/* 滞留时长 */} {durationLabel(t.duration_hours)} @@ -86,29 +102,85 @@ function WipRow({ t }: { t: WipTask }) { ); } +// ─── 留言列表项 ─────────────────────────────────────────── +function MsgRow({ m }: { m: ProductMessageItem }) { + const t = m.created_at ? dayjs(m.created_at).format("MM-DD HH:mm") : ""; + return ( + +
+
+ {m.operator_name} + {t} +
+
{m.content}
+
+ {m.product_sn} + {m.material_name} +
+
+
+ ); +} + // ─── 主组件 ─────────────────────────────────────────────── export default function AdminDashboard() { const [stats, setStats] = useState(null); const [wipTasks, setWipTasks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + + // 时间筛选 + const [dateKey, setDateKey] = useState("today"); + const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null); + + // 留言抽屉 + const [msgDrawerOpen, setMsgDrawerOpen] = useState(false); + const [msgKeyword, setMsgKeyword] = useState(""); + const [msgData, setMsgData] = useState([]); + const [msgTotal, setMsgTotal] = useState(0); + const [msgLoading, setMsgLoading] = useState(false); + const navigate = useNavigate(); - const loadData = () => { + // ── 加载主数据 ── + const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => { setLoading(true); + const { since, until } = rangeToParams(key, range); Promise.all([ - fetchDashboardStats(), + fetchDashboardStats(since, until), fetchWipTasks(20), ]) .then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); }) .catch(() => setError("加载失败,请确认后端已启动")) .finally(() => setLoading(false)); + }, []); + + useEffect(() => { loadData(dateKey, customRange); }, [dateKey, customRange]); + + // ── 加载留言 ── + const loadMessages = useCallback(async (kw: string) => { + setMsgLoading(true); + try { + const res = await fetchDashboardMessages(kw, 0, 50); + setMsgData(res.items); + setMsgTotal(res.total); + } catch { /* ignore */ } + finally { setMsgLoading(false); } + }, []); + + const openMsgDrawer = () => { + setMsgDrawerOpen(true); + setMsgKeyword(""); + loadMessages(""); }; - useEffect(() => { loadData(); }, []); + const onMsgSearch = (value: string) => { + setMsgKeyword(value); + loadMessages(value); + }; // ── 加载态 ── - if (loading) { + if (loading && !stats) { return (
@@ -116,33 +188,63 @@ export default function AdminDashboard() { ); } - // ── 错误态 ── if (error || !stats) { return (
{error || "数据为空"} - +
); } return (
- {/* ═══ 页头 ═══ */} -
+ {/* ═══ 页头 + 时间筛选器 ═══ */} +

📊 生产管理看板

- 产品 = 物理实体(身份证)| 任务 = 工序节点(流转步骤) + 产品 = 物理实体(身份证)| 任务 = 工序节点

- +
+ {/* 时间筛选 */} + { setDateKey(e.target.value); setCustomRange(null); }} + size="small" + optionType="button" + buttonStyle="solid" + > + 今天 + 近7天 + 近30天 + 自定义 + + {dateKey === "custom" && ( + setCustomRange(dates as [Dayjs, Dayjs] | null)} + style={{ width: 240 }} + placeholder={["开始", "结束"]} + /> + )} + +
- {/* ═══ 第1行:4 张概览卡片 ═══ */} + {/* 提示:已完结受时间筛选 */} + {dateKey !== "today" && ( +
+ 📐 当前时间筛选仅影响已完成/已驳回计数,在制品和总数始终为实时快照 +
+ )} + + {/* ═══ 4 卡片 ═══ */}
{/* 产品流转 */}
@@ -151,7 +253,7 @@ export default function AdminDashboard() {

📦 产品流转

{stats.products_total}

-

已登记产品 · 流转中/已完成

+

实时快照(不受时间筛选影响)

@@ -163,34 +265,39 @@ export default function AdminDashboard() {

📋 任务状态

{stats.tasks_total}

-

工序汇总 · 1产品=N任务

+

PENDING/WIP 实时 | COMPLETED 按时段

- {/* 品质 & 留言板未读 */} + {/* 品质 & 留言 */}
-

⚠️ 品质与提醒

+

⚠️ 品质与协同

{stats.tasks_rejected + stats.tasks_rework}

驳回/返工

-
-
- - 协同留言 -
- {stats.unread_messages} +
@@ -204,7 +311,7 @@ export default function AdminDashboard() {

{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}

@@ -212,26 +319,23 @@ export default function AdminDashboard() { strokeDasharray={`${stats.tasks_total > 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`} strokeLinecap="round" /> +

基于时间筛选后的已完成数

- {/* ═══ 第2行:在制品看板 + 快捷入口 ═══ */} + {/* ═══ 在制品 + 快捷入口 ═══ */}
- {/* 在制品看板 — 替换原来的"最近动态" */}

- 当前在制品 + 当前在制品(实时)

- - 滞留中 · 共 {wipTasks.length} 个任务 - + 共 {wipTasks.length} 个
{wipTasks.length === 0 ? ( -
🎉 当前没有滞留任务,所有工序已完结
+
🎉 暂无滞留任务
) : (
- {/* 表头 */}
状态 任务 · 负责人 @@ -248,38 +352,64 @@ export default function AdminDashboard() {
-
- -
-

- 💡 在制品 = 当前卡在工人手里的活
- 颜色越红 = 滞留越久。超过 - 48小时需关注, - 24小时内属正常。 -

-
+ + {/* ═══ 留言抽屉 ═══ */} + setMsgDrawerOpen(false)} + width={520} + styles={{ body: { padding: 0 } }} + > +
+ } + placeholder="搜索 SN码 / 物料名称 / 留言人 / 内容" + value={msgKeyword} + onChange={e => setMsgKeyword(e.target.value)} + onPressEnter={() => onMsgSearch(msgKeyword)} + allowClear + onClear={() => onMsgSearch("")} + /> +
+ } + pagination={{ + total: msgTotal, + pageSize: 30, + size: "small", + onChange: (page, size) => { + loadMessages(msgKeyword); + // simplified: re-fetch with skip + fetchDashboardMessages(msgKeyword, (page - 1) * size, size) + .then(res => { setMsgData(res.items); setMsgTotal(res.total); }) + .catch(() => {}); + }, + showTotal: (t) => `共 ${t} 条`, + }} + /> +
); } diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index e6eb5e5..e9ee433 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -25,8 +25,25 @@ export interface WipTask { duration_hours: number; } -export async function fetchDashboardStats(): Promise { - const { data } = await api.get("/dashboard/stats"); +export interface ProductMessageItem { + id: string; + content: string; + operator_name: string; + product_sn: string; + material_name: string; + created_at: string; +} + +export interface ProductMessageList { + items: ProductMessageItem[]; + total: number; +} + +export async function fetchDashboardStats(since?: string, until?: string): Promise { + const params: Record = {}; + if (since) params.since = since; + if (until) params.until = until; + const { data } = await api.get("/dashboard/stats", { params }); return data; } @@ -34,3 +51,12 @@ export async function fetchWipTasks(limit = 20): Promise { const { data } = await api.get("/dashboard/wip-tasks", { params: { limit } }); return data; } + +export async function fetchDashboardMessages( + keyword = "", skip = 0, limit = 30, +): Promise { + const { data } = await api.get("/dashboard/messages", { + params: { keyword, skip, limit }, + }); + return data; +}