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 (
@@ -60,30 +41,47 @@ function ProgressBar({ a, b, c, total, labels }: { ); } -// ─── 动态条目 ───────────────────────────────────────────── -function ActivityItem({ a }: { a: RecentActivity }) { - const iconMap: Record = { - "创建任务": "📋", "确认接收": "✅", "完成任务": "🏁", "完工转交": "🔄", - "品质驳回": "❌", "结束分支": "🛑", "撤回转交": "↩️", - }; +// ─── 滞留时间颜色 ───────────────────────────────────────── +function durationColor(h: number): string { + 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 { + 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 ( -
- {iconMap[a.action] || "📌"} +
+ {/* 状态标识 */} + + {t.status === "WIP" ? "进行中" : "待接收"} + + {/* 任务信息 */}
- {a.action} - {a.task_name} - {a.remark && ( - — {a.remark.slice(0, 30)}{a.remark.length > 30 ? "…" : ""} - )} + {t.task_name} + {t.assignee}
- {a.operator || "—"} - · - {a.product_sn} - {a.time} + {t.product_sn} + {t.received_at && 接收: {t.received_at}}
+ {/* 滞留时长 */} + + + {durationLabel(t.duration_hours)} +
); } @@ -91,29 +89,23 @@ function ActivityItem({ a }: { a: RecentActivity }) { // ─── 主组件 ─────────────────────────────────────────────── export default function AdminDashboard() { const [stats, setStats] = useState(null); - const [activity, setActivity] = useState([]); + const [wipTasks, setWipTasks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [dateRange, setDateRange] = useState("today"); const navigate = useNavigate(); - const loadData = (dr: DateRange) => { - const since = getSince(dr); + const loadData = () => { + setLoading(true); Promise.all([ fetchDashboardStats(), - fetchRecentActivity(15, since), + fetchWipTasks(20), ]) - .then(([s, a]) => { setStats(s); setActivity(a); setError(null); }) + .then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); }) .catch(() => setError("加载失败,请确认后端已启动")) .finally(() => setLoading(false)); }; - useEffect(() => { loadData(dateRange); }, [dateRange]); - - const handleDateChange = (dr: DateRange) => { - setLoading(true); - setDateRange(dr); - }; + useEffect(() => { loadData(); }, []); // ── 加载态 ── if (loading) { @@ -129,8 +121,7 @@ export default function AdminDashboard() { return (
{error || "数据为空"} - +
); } @@ -138,34 +129,17 @@ export default function AdminDashboard() { return (
{/* ═══ 页头 ═══ */} -
+

📊 生产管理看板

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

-
- {/* 日期筛选 */} -
- {DATE_OPTIONS.map(opt => ( - - ))} -
- -
+
{/* ═══ 第1行:4 张概览卡片 ═══ */} @@ -177,7 +151,7 @@ export default function AdminDashboard() {

📦 产品流转

{stats.products_total}

-

已登记的物理产品数量

+

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

@@ -189,43 +163,34 @@ export default function AdminDashboard() {

📋 任务状态

{stats.tasks_total}

-

产品下所有工序任务汇总

+

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

- {/* 品质 & 通知 — 通知可点击 */} + {/* 品质 & 留言板未读 */}
-

⚠️ 品质 & 通知

+

⚠️ 品质与提醒

-
+
-

{stats.tasks_rejected + stats.tasks_rework}

+

{stats.tasks_rejected + stats.tasks_rework}

驳回/返工

-
-
-
- {stats.tasks_rejected} -

已驳回

-
-
- {stats.tasks_rework} -

返工中

-
-
- {stats.unread_notifications} -

未读消息

+
+
+ + 协同留言
+ {stats.unread_messages}
@@ -250,26 +215,29 @@ export default function AdminDashboard() {
- {/* ═══ 第2行:最近动态 + 快捷入口 ═══ */} + {/* ═══ 第2行:在制品看板 + 快捷入口 ═══ */}
- {/* 最近动态 */} + {/* 在制品看板 — 替换原来的"最近动态" */}

- 最近流转动态 + 当前在制品

- {dateRange === "today" ? "今天" : dateRange === "7d" ? "近7天" : dateRange === "30d" ? "近30天" : "全部"} - · {activity.length} 条 + 滞留中 · 共 {wipTasks.length} 个任务
- {activity.length === 0 ? ( -
- {dateRange === "today" ? "今天暂无流转记录" : "该时间段暂无流转记录"} -
+ {wipTasks.length === 0 ? ( +
🎉 当前没有滞留任务,所有工序已完结
) : ( -
- {activity.map((a, i) => )} +
+ {/* 表头 */} +
+ 状态 + 任务 · 负责人 + 滞留 +
+ {wipTasks.map((t, i) => )}
)}
@@ -291,9 +259,7 @@ export default function AdminDashboard() { @@ -306,10 +272,10 @@ export default function AdminDashboard() {

- 💡 一个产品 = 多道工序
- 产品从创建到入库,经过装配→接线→质检等多道工序。 - 每个工序就是一个任务, - 由不同工人完成。任务数 ≥ 产品数是正常的。 + 💡 在制品 = 当前卡在工人手里的活
+ 颜色越红 = 滞留越久。超过 + 48小时需关注, + 24小时内属正常。

diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index 361f4b1..e6eb5e5 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -12,15 +12,17 @@ export interface DashboardStats { tasks_rejected: number; tasks_rework: number; unread_notifications: number; + unread_messages: number; } -export interface RecentActivity { - action: string; +export interface WipTask { + task_id: string; task_name: string; - operator: string; + assignee: string; product_sn: string; - time: string; - remark: string | null; + status: string; + received_at: string; + duration_hours: number; } export async function fetchDashboardStats(): Promise { @@ -28,14 +30,7 @@ export async function fetchDashboardStats(): Promise { return data; } -export async function fetchRecentActivity( - limit = 10, - since?: string, - until?: string, -): Promise { - const params: Record = { limit }; - if (since) params.since = since; - if (until) params.until = until; - const { data } = await api.get("/dashboard/recent-activity", { params }); +export async function fetchWipTasks(limit = 20): Promise { + const { data } = await api.get("/dashboard/wip-tasks", { params: { limit } }); return data; }