diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index cdcfbee..eb0f501 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -1,8 +1,11 @@ """Dashboard API""" -from fastapi import APIRouter, Depends +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 +from app.services.dashboard_service import ( + get_dashboard_stats, DashboardStats, + get_recent_activity, RecentActivity, +) router = APIRouter(prefix="/dashboard", tags=["管理看板"]) @@ -10,3 +13,12 @@ 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) + + +@router.get("/recent-activity", response_model=list[RecentActivity]) +async def recent_activity( + limit: int = Query(10, ge=1, le=50), + db: AsyncSession = Depends(get_db), +): + """最近任务动态 — 看板活动时间线""" + return await get_recent_activity(db, limit) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 793b051..8786908 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -5,19 +5,38 @@ from pydantic import BaseModel 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 + + +class RecentActivity(BaseModel): + action: str + task_name: str + operator: str + product_sn: str + time: str + remark: str | None = None 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 + from app.models.task import ( + Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, + TASK_STATUS_REJECTED, TASK_STATUS_ARCHIVED, + ) + from app.models.notification import Notification p_total = await db.scalar(select(func.count(Product.id))) p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending")) @@ -28,6 +47,12 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: 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))) + + unread = await db.scalar( + select(func.count(Notification.id)).where(Notification.is_read.is_(False)) + ) return DashboardStats( products_total=p_total or 0, @@ -38,4 +63,50 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: tasks_pending=t_pending or 0, tasks_in_progress=t_progress or 0, tasks_completed=t_done or 0, + tasks_rejected=t_rejected or 0, + tasks_rework=t_rework or 0, + unread_notifications=unread or 0, ) + + +async def get_recent_activity(db: AsyncSession, limit: int = 10) -> list[RecentActivity]: + from app.models.task_log import TaskLog + from app.models.task import Task + from app.models.product import Product + + stmt = ( + select(TaskLog, Task.task_name, Product.serial_number) + .join(Task, TaskLog.task_id == Task.id) + .join(Product, Task.product_id == Product.id) + .order_by(TaskLog.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + rows = result.all() + + activities: list[RecentActivity] = [] + for log, task_name, product_sn in rows: + action_label = _action_label(log.action_type) + time_str = log.created_at.strftime("%m-%d %H:%M") if log.created_at else "" + activities.append(RecentActivity( + action=action_label, + task_name=task_name or "", + operator=log.operator_id or "系统", + product_sn=product_sn or "", + time=time_str, + remark=log.remark, + )) + return activities + + +def _action_label(action_type: str) -> str: + labels = { + "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 004a9a2..262c3e0 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -1,80 +1,261 @@ import { useEffect, useState } from "react"; -import { Package, ClipboardList, Loader2, AlertCircle } from "lucide-react"; -import { fetchDashboardStats, type DashboardStats } from "../../services/dashboardApi"; +import { + Package, ClipboardList, Bell, TrendingUp, AlertTriangle, + RefreshCw, Loader2, AlertCircle, Plus, ArrowRight, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { + fetchDashboardStats, fetchRecentActivity, + type DashboardStats, type RecentActivity, +} from "../../services/dashboardApi"; -function StatCard({ - label, - total, - pending, - progress, - done, - icon: Icon, -}: { - label: string; - total: number; - pending: number; - progress: number; - done: number; - icon: React.ComponentType<{ className?: string }>; -}) { +// ─── 小卡片 ─────────────────────────────────────────────── +function MiniStat({ value, label, color }: { value: number; label: string; color: string }) { return ( -
-
- -

{label}

- {total} +
+ {value} +

{label}

+
+ ); +} + +// ─── 进度条 ─────────────────────────────────────────────── +function ProgressBar({ a, b, c, total, labels }: { + a: number; b: number; c: number; total: number; + labels: [string, string, string]; +}) { + if (total === 0) return
暂无数据
; + const pct = (n: number) => Math.round((n / total) * 100); + const segs = [ + { n: a, color: "bg-amber-400", label: labels[0] }, + { n: b, color: "bg-blue-500", label: labels[1] }, + { n: c, color: "bg-emerald-500", label: labels[2] }, + ].filter(s => s.n > 0); + + return ( +
+
+ {segs.map((s, i) => ( +
+ ))}
-
- {pending > 0 && ( -
- )} - {progress > 0 && ( -
- )} - {done > 0 && ( -
- )} -
-
- 待处理 {pending} - 进行中 {progress} - 已完成 {done} +
+ {segs.map((s, i) => ( + + + {s.label} {s.n}({pct(s.n)}%) + + ))}
); } +// ─── 动态条目 ───────────────────────────────────────────── +function ActivityItem({ a }: { a: RecentActivity }) { + const iconMap: Record = { + "创建任务": "📋", "确认接收": "✅", "完成任务": "🏁", "完工转交": "🔄", + "品质驳回": "❌", "结束分支": "🛑", "撤回转交": "↩️", + }; + return ( +
+ {iconMap[a.action] || "📌"} +
+
+ {a.action} + {a.task_name} +
+
+ {a.operator} + · + {a.product_sn.slice(0, 8)}… + {a.time} +
+
+
+ ); +} + +// ─── 主组件 ─────────────────────────────────────────────── export default function AdminDashboard() { const [stats, setStats] = useState(null); + const [activity, setActivity] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const navigate = useNavigate(); useEffect(() => { - fetchDashboardStats() - .then(setStats) + Promise.all([ + fetchDashboardStats(), + fetchRecentActivity(8), + ]) + .then(([s, a]) => { setStats(s); setActivity(a); }) .catch(() => setError("加载统计数据失败,请确认后端已启动")) .finally(() => setLoading(false)); }, []); + // ── 加载态 ── if (loading) { - return
; + return ( +
+ +
+ ); } - if (error) { - return
{error}
; + // ── 错误态 ── + if (error || !stats) { + return ( +
+ {error || "数据为空"} +
+ ); } - if (!stats) return null; - return ( -
-
-

全局生产概览

-

PC端与移动端共享同一后台数据

+
+ {/* ═══ 页头 ═══ */} +
+
+

📊 生产管理看板

+

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

+
+
-
- - + + {/* ═══ 第1行:4 张概览卡片 ═══ */} +
+ {/* 产品流转 */} +
+
+ +

📦 产品流转

+
+

{stats.products_total}

+

已登记的物理产品数量

+ +
+ + {/* 任务状态 */} +
+
+ +

📋 任务状态

+
+

{stats.tasks_total}

+

产品下所有工序任务汇总

+ +
+ + {/* 品质 & 通知 */} +
+
+ +

⚠️ 品质 & 通知

+
+
+
+

{stats.tasks_rejected + stats.tasks_rework}

+

驳回/返工

+
+
+

{stats.unread_notifications}

+

未读通知

+
+
+
+ + + +
+
+ + {/* 完成率 */} +
+
+ +

✅ 流转完成率

+
+
+

+ {stats.tasks_total > 0 ? Math.round((stats.tasks_completed / stats.tasks_total) * 100) : 0}% +

+

{stats.tasks_completed}/{stats.tasks_total} 已完成

+
+ {/* 简易环形图 */} +
+ + + 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`} + strokeLinecap="round" /> + +
+
+
+ + {/* ═══ 第2行:最近动态 + 快捷入口 ═══ */} +
+ {/* 最近动态 */} +
+
+

🕐 最近流转动态

+ 最新 8 条 +
+ {activity.length === 0 ? ( +
暂无流转记录
+ ) : ( +
+ {activity.map((a, i) => )} +
+ )} +
+ + {/* 快捷入口 */} +
+

⚡ 快捷入口

+
+ + + + +
+ + {/* 说明卡片 */} +
+

+ 💡 怎么理解?
+ 一个产品从创建到入库,会经过多道 + 工序(装配→接线→质检…)。 + 每道工序就是一个任务,由不同工人完成。 + 所以任务数 ≥ 产品数是正常的。 +

+
+
); diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index 4b8e493..4ebcc46 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -9,9 +9,26 @@ export interface DashboardStats { tasks_pending: number; tasks_in_progress: number; tasks_completed: number; + tasks_rejected: number; + tasks_rework: number; + unread_notifications: number; +} + +export interface RecentActivity { + action: string; + task_name: string; + operator: string; + product_sn: string; + time: string; + remark: string | null; } export async function fetchDashboardStats(): Promise { const { data } = await api.get("/dashboard/stats"); return data; } + +export async function fetchRecentActivity(limit = 10): Promise { + const { data } = await api.get("/dashboard/recent-activity", { params: { limit } }); + return data; +}