diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 76246f5..a38adbf 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -6,6 +6,8 @@ from app.core.database import get_db from app.services.dashboard_service import ( get_dashboard_stats, DashboardStats, get_wip_tasks, WipTask, + get_completed_tasks, CompletedTask, + get_people_workload, PersonWorkload, search_product_messages, ProductMessageList, ) @@ -38,6 +40,27 @@ async def wip_tasks( return await get_wip_tasks(db, limit) +@router.get("/completed-tasks", response_model=list[CompletedTask]) +async def completed_tasks( + since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), + until: str | None = Query(None, description="截止日期 ISO"), + limit: int = Query(200, ge=1, le=500), + 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_completed_tasks(db, since=since_dt, until=until_dt, limit=limit) + + +@router.get("/people-workload", response_model=list[PersonWorkload]) +async def people_workload( + db: AsyncSession = Depends(get_db), +): + """人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)""" + return await get_people_workload(db) + + @router.get("/messages", response_model=ProductMessageList) async def dashboard_messages( keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"), diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 872ff81..5773f40 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -37,6 +37,33 @@ class WipTask(BaseModel): duration_hours: float +class CompletedTask(BaseModel): + task_id: str + task_name: str # 完成的工序节点 + assignee: str # 完成人中文姓名 + product_sn: str # 16位HEX身份证 + external_serial: str | None # 业务序列号 + material_name: str # 产品名称(物料名称) + spec_model: str # 规格型号 + completed_at: str # 完成时间 ISO + + +class PersonDevice(BaseModel): + product_id: str + serial_number: str # 16位HEX身份证 + external_serial: str | None # 业务序列号 + material_name: str # 产品名称 + spec_model: str # 规格型号 + task_status: str # 该设备名下的状态 WIP/PENDING + + +class PersonWorkload(BaseModel): + assignee_id: str + assignee_name: str # 中文姓名 + device_count: int + devices: list[PersonDevice] + + class ProductMessageItem(BaseModel): id: str content: str @@ -180,6 +207,129 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]: return wip_list[:limit] +# ============================================================ +# 已完成任务明细(流转完成率下钻 — 按时段过滤) +# ============================================================ + +async def get_completed_tasks( + db: AsyncSession, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 200, +) -> list[CompletedTask]: + """按时段查询已完成任务明细(上帝视角),用于「流转完成率」卡片下钻。""" + from app.models.task import Task, TASK_STATUS_COMPLETED + from app.models.product import Product + from app.core.time_utils import BEIJING_TZ + + stmt = ( + select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) + .join(Product, Task.product_id == Product.id) + .where(Task.status == TASK_STATUS_COMPLETED) + ) + if since: + stmt = stmt.where(Task.completed_at >= since) + if until: + stmt = stmt.where(Task.completed_at <= until) + stmt = stmt.order_by(Task.completed_at.desc()).limit(limit) + result = await db.execute(stmt) + rows = result.all() + + 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(raw_ids) + + items: list[CompletedTask] = [] + for task, sn, ext, mat, spec in rows: + t = task.completed_at + if t: + if t.tzinfo is None: + from datetime import timezone as dt_timezone + t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ) + else: + t = t.astimezone(BEIJING_TZ) + time_str = t.isoformat() if t else "" + items.append(CompletedTask( + task_id=str(task.id), + task_name=task.task_name, + assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"), + product_sn=sn or "", + external_serial=ext or None, + material_name=mat or "", + spec_model=spec or "", + completed_at=time_str, + )) + return items + + +# ============================================================ +# 人员负载(按人聚合在制品设备 — 独立「人员看板」) +# ============================================================ + +async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]: + """上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重)。""" + from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP + from app.models.product import Product + + stmt = ( + select( + Task.assignee_id, + Product.id, Product.serial_number, Product.external_serial, + Product.material_name, Product.spec_model, + Task.status, + ) + .join(Product, Task.product_id == Product.id) + .where( + Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), + Task.assignee_id.isnot(None), + ) + .order_by(Task.assignee_id, Product.created_at.desc()) + ) + result = await db.execute(stmt) + rows = result.all() + + # 按 assignee 聚合,product 去重;状态优先级 WIP > PENDING + by_assignee: dict[str, dict[str, PersonDevice]] = {} + for row in rows: + assignee = row[0] + product_id = str(row[1]) + status = row[6] or "" + devices = by_assignee.setdefault(assignee, {}) + if product_id in devices: + # 已有该设备:若新状态为 WIP 则提升(更活跃) + if status == "WIP": + devices[product_id].task_status = "WIP" + continue + devices[product_id] = PersonDevice( + product_id=product_id, + serial_number=row[2] or "", + external_serial=row[3] or None, + material_name=row[4] or "", + spec_model=row[5] or "", + task_status=status, + ) + + raw_ids = list(by_assignee.keys()) + name_map: dict[str, str] = {} + if raw_ids: + from app.services.mom_cache import get_display_names + name_map = get_display_names(raw_ids) + + workloads = [ + PersonWorkload( + assignee_id=assignee, + assignee_name=name_map.get(assignee, assignee), + device_count=len(devices), + devices=list(devices.values()), + ) + for assignee, devices in by_assignee.items() + ] + workloads.sort(key=lambda w: w.device_count, reverse=True) + return workloads + + # ============================================================ # 协同留言搜索(上帝视角 — 全厂) # ============================================================ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 696bbe3..57f4a9e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage")); const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard")); const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage")); const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage")); +const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage")); const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage")); export default function App() { @@ -48,6 +49,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index bef73c0..0ccfdbc 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; -import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User } from "lucide-react"; +import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users } from "lucide-react"; import { useAuth } from "../../contexts/AuthContext"; const MENU = [ @@ -21,6 +21,12 @@ const MENU = [ icon: GitBranch, description: "身份证查任务树 · 裂变/返工可视化", }, + { + title: "人员看板", + path: "/admin/people", + icon: Users, + description: "按负责人查看在制品设备分布", + }, ]; export default function AdminLayout() { diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 4d5b96e..c7901ee 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -1,14 +1,14 @@ import { useEffect, useState, useCallback } from "react"; import { Package, ClipboardList, Bell, TrendingUp, AlertTriangle, - RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, + RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, CheckCircle2, } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Radio, DatePicker, Drawer, Input } from "antd"; import dayjs, { type Dayjs } from "dayjs"; import { - fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, - type DashboardStats, type WipTask, type ProductMessageItem, + fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, + type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, } from "../../services/dashboardApi"; const { RangePicker } = DatePicker; @@ -159,6 +159,35 @@ function MsgRow({ m }: { m: ProductMessageItem }) { ); } +// ─── 已完成明细项(卡片式) ───────────────────────────── +function CompletedRow({ t }: { t: CompletedTask }) { + const nav = useNavigate(); + const completedTime = t.completed_at ? dayjs(t.completed_at).format("YYYY-MM-DD HH:mm") : ""; + return ( +
t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)} + className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-emerald-200 hover:shadow-md" + > +
+ + {t.task_name} + | + 完成人: {t.assignee} + {completedTime} +
+
+ {t.material_name || "未知设备"} + | + {t.spec_model || "无规格"} + | + 序列号: {t.external_serial || "未录入"} + | + 身份证: {t.product_sn} +
+
+ ); +} + // ─── 主组件 ─────────────────────────────────────────────── export default function AdminDashboard() { const [stats, setStats] = useState(null); @@ -177,6 +206,11 @@ export default function AdminDashboard() { const [msgTotal, setMsgTotal] = useState(0); const [msgLoading, setMsgLoading] = useState(false); + // 完成明细抽屉 + const [completedDrawerOpen, setCompletedDrawerOpen] = useState(false); + const [completedTasks, setCompletedTasks] = useState([]); + const [completedLoading, setCompletedLoading] = useState(false); + const navigate = useNavigate(); // ── 加载主数据 ── @@ -211,6 +245,16 @@ export default function AdminDashboard() { loadMessages(""); }; + const openCompletedDrawer = () => { + setCompletedDrawerOpen(true); + setCompletedLoading(true); + const { since, until } = rangeToParams(dateKey, customRange); + fetchCompletedTasks(since, until) + .then(setCompletedTasks) + .catch(() => setCompletedTasks([])) + .finally(() => setCompletedLoading(false)); + }; + const onMsgSearch = (value: string) => { setMsgKeyword(value); loadMessages(value); @@ -338,8 +382,11 @@ export default function AdminDashboard() { - {/* 完成率 */} -
+ {/* 完成率 — 可点击下钻 */} +
{/* ═══ 在制品 + 快捷入口 ═══ */} @@ -457,6 +504,29 @@ export default function AdminDashboard() { )} + + {/* ═══ 完成明细抽屉 ═══ */} + ✅ 已完成明细 {completedTasks.length} 条} + open={completedDrawerOpen} + onClose={() => setCompletedDrawerOpen(false)} + size="large" + styles={{ body: { padding: 16, background: "#f8fafc" } }} + > + {completedLoading ? ( +
+ +
+ ) : completedTasks.length === 0 ? ( +
+ 该时段暂无已完成任务 +
+ ) : ( +
+ {completedTasks.map(t => )} +
+ )} +
); } diff --git a/frontend/src/pages/admin/AdminPeoplePage.tsx b/frontend/src/pages/admin/AdminPeoplePage.tsx new file mode 100644 index 0000000..719fca1 --- /dev/null +++ b/frontend/src/pages/admin/AdminPeoplePage.tsx @@ -0,0 +1,153 @@ +/** 人员看板 — 按负责人聚合当前在制品设备 */ +import { useEffect, useMemo, useState } from "react"; +import { + Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { fetchPeopleWorkload, type PersonWorkload } from "../../services/dashboardApi"; + +const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#6366f1"]; + +export default function AdminPeoplePage() { + const [workloads, setWorkloads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const navigate = useNavigate(); + + const load = () => { + setLoading(true); + fetchPeopleWorkload() + .then((data) => { setWorkloads(data); setError(null); }) + .catch(() => setError("加载失败,请确认后端已启动")) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const totalDevices = useMemo( + () => workloads.reduce((sum, w) => sum + w.device_count, 0), + [workloads], + ); + + function toggle(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {/* 页头 */} +
+
+

👥 人员看板

+

按负责人聚合当前在制品设备 · 实时快照

+
+ +
+ + {/* 统计概览 */} +
+
+
+ +

在岗人员

+
+

{workloads.length}

+

持有在制品的负责人数

+
+
+
+ +

在制品设备总量

+
+

{totalDevices}

+

一台设备若被多人并发处理,会在多人名下各计一次

+
+
+ + {/* 加载 / 错误 / 空态 */} + {loading && ( +
+ +
+ )} + {!loading && error && ( +
+ {error} +
+ )} + {!loading && !error && workloads.length === 0 && ( +
🎉 当前无人在制品
+ )} + + {/* 人员列表 */} + {!loading && !error && workloads.length > 0 && ( +
+ {workloads.map((w) => { + const isOpen = expanded.has(w.assignee_id); + const initial = (w.assignee_name || w.assignee_id || "?").charAt(0); + const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length]; + return ( +
+ {/* 人员头部 */} + + + {/* 展开明细 */} + {isOpen && ( +
+ {w.devices.length === 0 ? ( +

无设备

+ ) : ( +
+ {w.devices.map((d) => ( +
navigate(`/admin/tasks?sn=${d.serial_number}`)} + className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md" + > + + {d.task_status === "WIP" ? "进行中" : "待接收"} + + {d.material_name || "未知设备"} + {d.spec_model || "无规格"} + 序列号: {d.external_serial || "未录入"} + 身份证: {d.serial_number} +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index 2813fc8..2f56cdb 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -28,6 +28,33 @@ export interface WipTask { duration_hours: number; } +export interface CompletedTask { + task_id: string; + task_name: string; + assignee: string; + product_sn: string; + external_serial: string | null; + material_name: string; + spec_model: string; + completed_at: string; +} + +export interface PersonDevice { + product_id: string; + serial_number: string; + external_serial: string | null; + material_name: string; + spec_model: string; + task_status: string; +} + +export interface PersonWorkload { + assignee_id: string; + assignee_name: string; + device_count: number; + devices: PersonDevice[]; +} + export interface ProductMessageItem { id: string; content: string; @@ -56,6 +83,19 @@ export async function fetchWipTasks(limit = 20): Promise { return data; } +export async function fetchCompletedTasks(since?: string, until?: string): Promise { + const params: Record = {}; + if (since) params.since = since; + if (until) params.until = until; + const { data } = await api.get("/dashboard/completed-tasks", { params }); + return data; +} + +export async function fetchPeopleWorkload(): Promise { + const { data } = await api.get("/dashboard/people-workload"); + return data; +} + export async function fetchDashboardMessages( keyword = "", skip = 0, limit = 30, ): Promise {