From 721cfe15041ed1e81b3cf08a90bf70aa13383ce9 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 7 Aug 2026 11:44:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Web=E7=AB=AF401=E9=98=9F=E5=88=97?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E5=99=A8=20+=20=E6=B6=88=E6=81=AF/=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1/=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=E9=87=8D?= =?UTF-8?q?=E5=86=99=20+=20TabBar=E7=BA=A2=E7=82=B9=20+=20Admin=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 28 ++ frontend/package.json | 1 + .../src/components/TaskTree/TaskFlowView.tsx | 288 +++++++++++ .../components/TaskTree/TaskTreeViewer.tsx | 246 +-------- .../src/components/layout/AdminLayout.tsx | 2 +- frontend/src/components/layout/AppLayout.tsx | 103 +++- frontend/src/components/scan/ManualInput.tsx | 4 +- frontend/src/components/scan/ProductCard.tsx | 24 +- frontend/src/components/scan/TaskListCard.tsx | 22 +- frontend/src/constants/task.ts | 71 +++ frontend/src/contexts/AuthContext.tsx | 34 +- frontend/src/pages/MyTasksPage.tsx | 209 +++++++- frontend/src/pages/NotificationsPage.tsx | 183 ++++++- frontend/src/pages/ProfilePage.tsx | 72 ++- frontend/src/pages/ScanPage.tsx | 36 +- .../src/pages/admin/AdminProductsPage.tsx | 130 +++-- frontend/src/pages/admin/AdminTasksPage.tsx | 473 +++++++++++++++++- .../src/pages/admin/CreateProductDialog.tsx | 6 +- frontend/src/services/api.ts | 143 +++++- frontend/src/services/authApi.ts | 23 +- frontend/src/services/notificationApi.ts | 41 ++ frontend/src/services/productApi.ts | 2 +- frontend/src/types/admin.ts | 1 + frontend/src/types/api.ts | 14 + frontend/vite.config.ts | 9 +- 25 files changed, 1730 insertions(+), 435 deletions(-) create mode 100644 frontend/src/components/TaskTree/TaskFlowView.tsx create mode 100644 frontend/src/constants/task.ts create mode 100644 frontend/src/services/notificationApi.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 52fca72..9be1fc5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-virtual": "^3.14.9", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-shell": "^2.3.5", "antd": "^6.5.3", @@ -1714,6 +1715,33 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tauri-apps/api": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index aa89db4..6c32e25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-virtual": "^3.14.9", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-shell": "^2.3.5", "antd": "^6.5.3", diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx new file mode 100644 index 0000000..5a6880d --- /dev/null +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -0,0 +1,288 @@ +/** + * 任务流转卡片堆叠视图 — 水平泳道 + 卡片层叠布局 + * + * 将递归任务树按层级拆分为水平泳道,每层卡片横向排列, + * 当前激活(WIP/PENDING)卡片高亮居中,操作按钮集成在卡片底部。 + */ +import { memo, useMemo } from "react"; +import { + GitBranch, + ArrowDown, + AlertTriangle, + Clock, +} from "lucide-react"; +import type { TaskResponse } from "../../types/api"; +import { TASK_STATUS } from "../../types/api"; +import { getStatusConfig } from "../../constants/task"; +import type { ModalTarget } from "./TaskTreeViewer"; + +// ---- 耗时计算(复用) ---- + +function calcDwell( + receivedAt: string | null, + completedAt: string | null, + status: string, +): { text: string; highlight: boolean } | null { + if (!receivedAt) return null; + const start = new Date(receivedAt).getTime(); + const end = completedAt ? new Date(completedAt).getTime() : Date.now(); + const diffMs = end - start; + if (diffMs < 0) return null; + + const totalMin = Math.floor(diffMs / 60000); + if (totalMin < 1) return { text: "< 1分钟", highlight: status === "WIP" }; + if (totalMin < 60) return { text: `${totalMin}分钟`, highlight: status === "WIP" }; + + const hours = Math.floor(totalMin / 60); + const remainMin = totalMin % 60; + if (hours < 24) { + return { text: `${hours}小时${remainMin > 0 ? remainMin + "分钟" : ""}`, highlight: status === "WIP" }; + } + + const days = Math.floor(hours / 24); + const remainHr = hours % 24; + return { text: `${days}天${remainHr > 0 ? remainHr + "小时" : ""}`, highlight: status === "WIP" }; +} + +// ---- 类型 ---- + +interface LevelGroup { + depth: number; + tasks: TaskResponse[]; +} + +// ---- 将递归树拍平为层级 ---- + +function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] { + const result: LevelGroup[] = []; + if (!tasks || tasks.length === 0) return result; + + // 当前层级 + result.push({ depth, tasks }); + + // 递归子层级 + for (const t of tasks) { + if (t.child_tasks && t.child_tasks.length > 0) { + const childLevels = flattenLevels(t.child_tasks, depth + 1); + for (const cl of childLevels) { + // 合并同深度的层级 + const existing = result.find((r) => r.depth === cl.depth && r !== result[result.indexOf({ depth, tasks })] ); + // 简化:直接 push,在渲染时按 depth 分组 + } + result.push(...childLevels); + } + } + + return result; +} + +/** 按 depth 聚合所有层级 */ +function groupByDepth(levels: LevelGroup[]): Map { + const map = new Map(); + for (const lvl of levels) { + if (!map.has(lvl.depth)) map.set(lvl.depth, []); + map.get(lvl.depth)!.push(...lvl.tasks); + } + return map; +} + +// ---- 单张任务卡片 ---- + +const FlowCard = memo(function FlowCard({ + task, + isActive, + onAction, +}: { + task: TaskResponse; + isActive: boolean; + onAction: (target: ModalTarget) => void; +}) { + const cfg = getStatusConfig(task.status); + const dwell = calcDwell(task.received_at, task.completed_at, task.status); + const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED; + const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED; + + return ( +
+ {/* 返工标记 */} + {task.is_rework && ( +
+ 返工 +
+ )} + + {/* 裂变标记 */} + {task.child_tasks.length > 1 && ( +
+ + 裂变×{task.child_tasks.length} +
+ )} + + {/* 任务名 */} +

{task.task_name}

+ + {/* 状态 Badge */} +
+ + {cfg.label} + + {task.assignee_id && ( + + {task.assignee_id} + + )} +
+ + {/* 停留耗时 */} + {dwell && ( +

+ + {dwell.highlight ? ⏳ {dwell.text} : {dwell.text}} +

+ )} + + {/* 驳回原因 */} + {task.reject_reason && ( +

{task.reject_reason}

+ )} + + {/* 日期 */} +

+ {task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`} +

+ + {/* 操作按钮 — 仅激活态显示 */} + {isActive && ( +
+ {task.status?.toUpperCase() === TASK_STATUS.PENDING && ( + <> + + + + + )} + {task.status?.toUpperCase() === TASK_STATUS.WIP && ( + <> + + + + )} +
+ )} +
+ ); +}); + +// ---- 主视图 ---- + +interface TaskFlowViewProps { + tasks: TaskResponse[]; + onAction: (target: ModalTarget) => void; +} + +export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction }: TaskFlowViewProps) { + // 按 depth 分组的层级数据 + const depthMap = useMemo(() => { + if (!tasks || tasks.length === 0) return new Map(); + const flat = flattenLevels(tasks); + // 按 depth 聚合去重 + const merged = new Map>(); + for (const lvl of flat) { + if (!merged.has(lvl.depth)) merged.set(lvl.depth, new Map()); + const inner = merged.get(lvl.depth)!; + for (const t of lvl.tasks) { + if (!inner.has(t.id)) inner.set(t.id, t); + } + } + const result = new Map(); + for (const [depth, idMap] of merged) { + result.set(depth, Array.from(idMap.values())); + } + return result; + }, [tasks]); + + const depths = Array.from(depthMap.keys()).sort((a, b) => a - b); + + if (depths.length === 0) return null; + + return ( +
+ {depths.map((depth) => { + const levelTasks = depthMap.get(depth) || []; + return ( +
+ {/* 层级标签 */} +
+ + {depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`} + +
+
+ + {/* 卡片横向排列 */} +
+ {levelTasks.map((task) => { + const isActive = + task.status?.toUpperCase() === TASK_STATUS.PENDING || + task.status?.toUpperCase() === TASK_STATUS.WIP; + return ( +
+ +
+ ); + })} +
+ + {/* 层级间连接箭头 */} + {depth < depths.length - 1 && ( +
+ +
+ )} +
+ ); + })} +
+ ); +}); + +export default TaskFlowView; diff --git a/frontend/src/components/TaskTree/TaskTreeViewer.tsx b/frontend/src/components/TaskTree/TaskTreeViewer.tsx index bb9dea3..a441ed7 100644 --- a/frontend/src/components/TaskTree/TaskTreeViewer.tsx +++ b/frontend/src/components/TaskTree/TaskTreeViewer.tsx @@ -10,6 +10,7 @@ import { Warehouse, UserPlus, } from "lucide-react"; +import TaskFlowView from "./TaskFlowView"; import { getTaskTree, receiveTask, @@ -17,59 +18,8 @@ import { transferTask, } from "../../services/taskApi"; import { useToast } from "../ui/Toast"; -import type { ProductScanResponse, TaskResponse, TaskStatus } from "../../types/api"; -import { TASK_STATUS } from "../../types/api"; - -// ============================================================ -// 状态 → 颜色/标签映射 -// ============================================================ - -const STATUS_CONFIG: Record< - string, - { bg: string; text: string; ring: string; label: string } -> = { - [TASK_STATUS.PENDING]: { - bg: "bg-yellow-50", - text: "text-yellow-700", - ring: "ring-yellow-400", - label: "待接收", - }, - [TASK_STATUS.WIP]: { - bg: "bg-blue-50", - text: "text-blue-700", - ring: "ring-blue-400", - label: "进行中", - }, - [TASK_STATUS.COMPLETED]: { - bg: "bg-green-50", - text: "text-green-700", - ring: "ring-green-400", - label: "已完成", - }, - [TASK_STATUS.REJECTED]: { - bg: "bg-red-50", - text: "text-red-700", - ring: "ring-red-400", - label: "已驳回", - }, - [TASK_STATUS.ARCHIVED]: { - bg: "bg-gray-50", - text: "text-gray-600", - ring: "ring-gray-300", - label: "已入库", - }, -}; - -function getStatusConfig(status: string) { - return ( - STATUS_CONFIG[status] ?? { - bg: "bg-gray-50", - text: "text-gray-600", - ring: "ring-gray-300", - label: status, - } - ); -} +import type { ProductScanResponse, TaskResponse } from "../../types/api"; +import { getStatusConfig } from "../../constants/task"; // ============================================================ // 通用 Modal 容器 @@ -116,7 +66,7 @@ const Modal = memo(function Modal({ // 确认接收弹窗 // ============================================================ -const ReceiveConfirmModal = memo(function ReceiveConfirmModal({ +export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({ open, task, submitting, @@ -169,7 +119,7 @@ const ReceiveConfirmModal = memo(function ReceiveConfirmModal({ // 品质驳回弹窗 // ============================================================ -const RejectModal = memo(function RejectModal({ +export const RejectModal = memo(function RejectModal({ open, task, submitting, @@ -250,7 +200,7 @@ const RejectModal = memo(function RejectModal({ // 完工裂变转交弹窗 // ============================================================ -const TransferModal = memo(function TransferModal({ +export const TransferModal = memo(function TransferModal({ open, task, submitting, @@ -499,162 +449,14 @@ const TransferModal = memo(function TransferModal({ }); // ============================================================ -// 单个任务节点卡片 +// 操作弹窗目标类型 // ============================================================ -interface ModalTarget { +export interface ModalTarget { task: TaskResponse; action: "receive" | "reject" | "transfer"; } -const TaskNodeCard = memo(function TaskNodeCard({ - task, - isLast, - onAction, -}: { - task: TaskResponse; - isLast: boolean; - onAction: (target: ModalTarget) => void; -}) { - const { bg, text, ring, label } = getStatusConfig(task.status); - - return ( -
- {/* 树形连接线 */} - {task.child_tasks.length > 0 && ( - <> -
- {task.child_tasks.length > 1 && ( -
- )} - - )} - - {/* 卡片本体 */} -
-
- {/* 左侧:任务名 + 标签 */} -
-
- {task.is_rework && ( - - ⚠ 返工 - - )} - {task.child_tasks.length > 1 && ( - - - 裂变×{task.child_tasks.length} - - )} - - {task.task_name} - -
- -
- {task.assignee_id && 负责人: {task.assignee_id}} - {task.received_at && ( - - 接收: {new Date(task.received_at).toLocaleDateString("zh-CN")} - - )} - {task.completed_at && ( - - 完成:{" "} - {new Date(task.completed_at).toLocaleDateString("zh-CN")} - - )} -
- - {task.reject_reason && ( -

- 驳回原因: {task.reject_reason} -

- )} -
- - {/* 右侧:状态标签 + 操作按钮 */} -
- - {label} - - - {/* 操作按钮 */} - {task.status === TASK_STATUS.PENDING && ( -
- - - -
- )} - {task.status === TASK_STATUS.WIP && ( -
- - -
- )} -
-
-
- - {/* 递归渲染子任务 */} - {task.child_tasks.length > 0 && ( -
- {task.child_tasks.map((child, idx) => ( - - ))} -
- )} -
- ); -}); - // ============================================================ // 主容器组件 // ============================================================ @@ -688,7 +490,7 @@ export default function TaskTreeViewer() { e?.preventDefault(); const trimmed = serial.trim(); if (trimmed.length !== 16) { - setError("请输入 16 位产品序列号"); + setError("请输入 16 位产品身份证"); return; } @@ -702,7 +504,7 @@ export default function TaskTreeViewer() { } catch (err: any) { const msg = err?.response?.status === 404 - ? `未找到序列号 ${trimmed} 对应的产品` + ? `未找到产品身份证 ${trimmed} 对应的产品` : err?.response?.data?.detail ?? err?.message ?? "查询失败,请检查后端服务"; @@ -793,7 +595,7 @@ export default function TaskTreeViewer() {

任务全景树

- 输入 16 位产品序列号,查看完整任务流转十字矩阵树状图 + 输入 16 位产品身份证,查看完整任务流转十字矩阵树状图

@@ -803,7 +605,7 @@ export default function TaskTreeViewer() { type="text" value={serial} onChange={(e) => setSerial(e.target.value)} - placeholder="输入 16 位序列号,如 X20260801000001" + placeholder="输入 16 位产品身份证,如 X20260801000001" maxLength={16} className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 font-mono text-sm tracking-widest placeholder:tracking-normal placeholder:font-sans focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" /> @@ -855,7 +657,7 @@ export default function TaskTreeViewer() {
- 产品序列号 + 产品身份证

{product.serial_number}

@@ -894,25 +696,15 @@ export default function TaskTreeViewer() {
)} - {/* ---- 任务树 ---- */} + {/* ---- 任务流转泳道/卡片视图 ---- */} {product && !loading && ( <> {product.task_tree && product.task_tree.length > 0 ? ( -
-

- - 任务流转树状图 -

-
- {product.task_tree.map((task, idx) => ( - - ))} -
+
+
) : (
diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 6be1ab3..9cb0faa 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -19,7 +19,7 @@ const MENU = [ title: "任务全景", path: "/admin/tasks", icon: GitBranch, - description: "序列号查任务树 · 裂变/返工可视化", + description: "身份证查任务树 · 裂变/返工可视化", }, ]; diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 644dd94..d477acc 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,7 +1,10 @@ -import { NavLink, Outlet } from "react-router-dom"; -import { ScanLine, ClipboardList, Bell, User } from "lucide-react"; +import { useEffect, useState } from "react"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { ScanLine, ClipboardList, Bell, User, ArrowLeft } from "lucide-react"; +import { useAuth } from "../../contexts/AuthContext"; +import { getNotifications } from "../../services/notificationApi"; -/** 底部导航 Tab 配置 */ +/** 底部导航 Tab 配置 — 与 uni-app pages.json tabBar.list 完全一致 */ const TABS = [ { path: "/scan", label: "扫码干活", icon: ScanLine }, { path: "/tasks", label: "我的任务", icon: ClipboardList }, @@ -10,38 +13,92 @@ const TABS = [ ] as const; /** 导航栏高度(供页面计算偏移量) */ -export const TAB_BAR_HEIGHT = 64; // px(h-16) +export const TAB_BAR_HEIGHT = 64; +const TOP_BAR_HEIGHT = 48; export default function AppLayout() { + const { user } = useAuth(); + const navigate = useNavigate(); + + const [unreadCount, setUnreadCount] = useState(0); + + // 🔔 未读消息数轮询 + useEffect(() => { + const userId = user?.username || user?.id || ""; + if (!userId) return; + + function poll() { + getNotifications(userId, 0, 1) + .then((res) => setUnreadCount(res.unread_count || 0)) + .catch(() => {}); // 静默 + } + + poll(); // 立即请求一次 + const id = setInterval(poll, 30_000); // 每 30 秒轮询 + return () => clearInterval(id); + }, [user]); + return (
+ {/* ======== 顶部导航栏 — 返回管理端 ======== */} +
+ + 生产流转 · 移动端 +
+ {/* ======== 主内容区 ======== */} -
+
- {/* ======== 底部导航栏 ======== */} + {/* ======== 底部导航栏 — 与 uni-app tabBar 完全一致 ======== */}
diff --git a/frontend/src/components/scan/ManualInput.tsx b/frontend/src/components/scan/ManualInput.tsx index de36ca2..4e3ba6f 100644 --- a/frontend/src/components/scan/ManualInput.tsx +++ b/frontend/src/components/scan/ManualInput.tsx @@ -1,4 +1,4 @@ -/** 手动输入序列号区域 */ +/** 手动输入产品身份证区域 */ import { Search, Loader2 } from "lucide-react"; interface ManualInputProps { @@ -29,7 +29,7 @@ export default function ManualInput({ value={value} onChange={(e) => onChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder="手动输入16位序列号" + placeholder="手动输入16位产品身份证" maxLength={16} className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none" /> diff --git a/frontend/src/components/scan/ProductCard.tsx b/frontend/src/components/scan/ProductCard.tsx index b0660e0..6f80d05 100644 --- a/frontend/src/components/scan/ProductCard.tsx +++ b/frontend/src/components/scan/ProductCard.tsx @@ -1,25 +1,7 @@ /** 产品信息卡片 */ import { Package } from "lucide-react"; import type { ProductScanResponse } from "../../types/api"; -import { TASK_STATUS } from "../../types/api"; - -const STATUS_LABELS: Record = { - [TASK_STATUS.PENDING]: "待接收", - [TASK_STATUS.WIP]: "进行中", - [TASK_STATUS.COMPLETED]: "已完成", - [TASK_STATUS.REJECTED]: "已驳回", - [TASK_STATUS.ARCHIVED]: "已入库", -}; - -function statusColor(status: string): string { - switch (status) { - case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700"; - case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700"; - case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700"; - case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700"; - default: return "bg-gray-100 text-gray-600"; - } -} +import { statusColor, statusLabel } from "../../constants/task"; interface ProductCardProps { product: ProductScanResponse; @@ -32,12 +14,12 @@ export default function ProductCard({ product }: ProductCardProps) {

产品信息

- {STATUS_LABELS[product.status] ?? product.status} + {statusLabel(product.status)}
- 序列号 + 身份证

{product.serial_number}

diff --git a/frontend/src/components/scan/TaskListCard.tsx b/frontend/src/components/scan/TaskListCard.tsx index c123d4e..88dbe05 100644 --- a/frontend/src/components/scan/TaskListCard.tsx +++ b/frontend/src/components/scan/TaskListCard.tsx @@ -2,25 +2,7 @@ import { useMemo, memo } from "react"; import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react"; import type { TaskResponse, TaskSummary } from "../../types/api"; -import { TASK_STATUS } from "../../types/api"; - -const STATUS_LABELS: Record = { - [TASK_STATUS.PENDING]: "待接收", - [TASK_STATUS.WIP]: "进行中", - [TASK_STATUS.COMPLETED]: "已完成", - [TASK_STATUS.REJECTED]: "已驳回", - [TASK_STATUS.ARCHIVED]: "已入库", -}; - -function statusColor(status: string): string { - switch (status) { - case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700"; - case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700"; - case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700"; - case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700"; - default: return "bg-gray-100 text-gray-600"; - } -} +import { statusColor, statusLabel } from "../../constants/task"; interface TaskListCardProps { tasks: TaskResponse[]; @@ -55,7 +37,7 @@ const TaskNode = memo(function TaskNode({ task, depth }: TaskNodeProps) {

- {STATUS_LABELS[task.status] ?? task.status} + {statusLabel(task.status)}
{task.child_tasks?.map((child) => ( diff --git a/frontend/src/constants/task.ts b/frontend/src/constants/task.ts new file mode 100644 index 0000000..f885bf7 --- /dev/null +++ b/frontend/src/constants/task.ts @@ -0,0 +1,71 @@ +/** + * 任务状态 → 颜色/标签映射(全局唯一数据源) + * + * 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用, + * 避免在多处重复维护相同的映射逻辑。 + */ +import { TASK_STATUS } from "../types/api"; + +export interface StatusStyle { + bg: string; + text: string; + ring: string; + label: string; +} + +export const STATUS_CONFIG: Record = { + [TASK_STATUS.PENDING]: { + bg: "bg-yellow-50", + text: "text-yellow-700", + ring: "ring-yellow-400", + label: "待接收", + }, + [TASK_STATUS.WIP]: { + bg: "bg-blue-50", + text: "text-blue-700", + ring: "ring-blue-400", + label: "进行中", + }, + [TASK_STATUS.COMPLETED]: { + bg: "bg-green-50", + text: "text-green-700", + ring: "ring-green-400", + label: "已完成", + }, + [TASK_STATUS.REJECTED]: { + bg: "bg-red-50", + text: "text-red-700", + ring: "ring-red-400", + label: "待接收", + }, + [TASK_STATUS.ARCHIVED]: { + bg: "bg-gray-50", + text: "text-gray-600", + ring: "ring-gray-300", + label: "已完成", + }, +}; + +const FALLBACK: StatusStyle = { + bg: "bg-gray-50", + text: "text-gray-600", + ring: "ring-gray-300", + label: "", +}; + +/** 获取状态的完整样式配置(bg / text / ring / label)— 大小写不敏感 */ +export function getStatusConfig(status: string): StatusStyle { + const key = status?.toUpperCase?.() ?? status; + return STATUS_CONFIG[key] ?? { ...FALLBACK, label: status }; +} + +/** 仅获取 bg + text 类名,用于简单的状态标签着色 */ +export function statusColor(status: string): string { + const cfg = getStatusConfig(status); + return `${cfg.bg} ${cfg.text}`; +} + +/** 仅获取中文标签 */ +export function statusLabel(status: string): string { + return getStatusConfig(status).label; +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 69f117e..7066608 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -23,7 +23,7 @@ export interface UserInfo { interface AuthState { user: UserInfo | null; token: string | null; - loading: boolean; // 初始化时检查 token + loading: boolean; } interface AuthContextValue extends AuthState { @@ -36,7 +36,8 @@ interface AuthContextValue extends AuthState { // Token 存储 key // ============================================================ -const TOKEN_KEY = "track_admin_token"; +const ACCESS_TOKEN_KEY = "track_admin_token"; +const REFRESH_TOKEN_KEY = "track_admin_refresh_token"; const USER_KEY = "track_admin_user"; // ============================================================ @@ -62,23 +63,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading: true, }); - // 初始化:从 localStorage 恢复 token + // 初始化:从 localStorage 恢复双 Token useEffect(() => { - const savedToken = localStorage.getItem(TOKEN_KEY); + const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY); const savedUser = localStorage.getItem(USER_KEY); if (savedToken && savedUser) { try { const user = JSON.parse(savedUser) as UserInfo; setState({ user, token: savedToken, loading: false }); - // 可选:后端验证 token 是否仍有效 + // 后台静默验证 token 是否仍有效 getMe(savedToken) .then((fresh) => { setState((prev) => ({ ...prev, user: fresh })); localStorage.setItem(USER_KEY, JSON.stringify(fresh)); }) - .catch(() => { - // token 过期,清除 - logoutInternal(); + .catch((err) => { + // 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器 + // 通过 Refresh Token 无感刷新,彻底失败才跳转登录 + console.error( + "[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):", + err?.message ?? err, + ); }); } catch { logoutInternal(); @@ -89,26 +94,29 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); function logoutInternal() { - localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(USER_KEY); setState({ user: null, token: null, loading: false }); } const login = useCallback(async (username: string, password: string) => { const result = await loginApi(username, password); - const token = result.access_token; + const accessToken = result.access_token; + const refreshToken = result.refresh_token; const user: UserInfo = result.user; - localStorage.setItem(TOKEN_KEY, token); + // 存储双 Token + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); localStorage.setItem(USER_KEY, JSON.stringify(user)); - setState({ user, token, loading: false }); + setState({ user, token: accessToken, loading: false }); }, []); const logout = useCallback(() => { logoutInternal(); }, []); - // 🚀 稳定 Context value 引用,避免消费者不必要的 re-render const ctxValue = useMemo( () => ({ ...state, diff --git a/frontend/src/pages/MyTasksPage.tsx b/frontend/src/pages/MyTasksPage.tsx index 382560e..17f945e 100644 --- a/frontend/src/pages/MyTasksPage.tsx +++ b/frontend/src/pages/MyTasksPage.tsx @@ -1,29 +1,200 @@ -/** 待办 — 我的任务列表 */ +/** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */ +import { useEffect, useState, useMemo } from "react"; +import { ClipboardList } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import api from "../services/api"; +import { useAuth } from "../contexts/AuthContext"; +import type { TaskSummary } from "../types/api"; + +// ============================================================ +// 常量 — 与 uni-app 完全一致 +// ============================================================ + +const TABS = [ + { key: "all", label: "全部" }, + { key: "PENDING", label: "待接收" }, + { key: "WIP", label: "进行中" }, + { key: "COMPLETED", label: "已完成" }, +] as const; + +type TabKey = (typeof TABS)[number]["key"]; + +const STATUS_LABEL: Record = { + PENDING: "待接收", + WIP: "进行中", + COMPLETED: "已完成", + REJECTED: "已驳回", + ARCHIVED: "已入库", + CANCELED: "已撤回", +}; + +/** 状态 → Tailwind 颜色类名 */ +function statusColor(s: string) { + switch (s) { + case "PENDING": + return "bg-yellow-50 text-yellow-700"; + case "WIP": + return "bg-blue-50 text-blue-700"; + case "COMPLETED": + return "bg-green-50 text-green-700"; + default: + return "bg-gray-100 text-gray-600"; + } +} + +function formatTime(t: string) { + if (!t) return ""; + const d = new Date(t); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// ============================================================ +// Page 组件 +// ============================================================ + export default function MyTasksPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + + const [tab, setTab] = useState("PENDING"); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchTasks(); + }, [user]); // eslint-disable-line react-hooks/exhaustive-deps + + async function fetchTasks() { + setLoading(true); + try { + const username = user?.username || ""; + const { data } = await api.get<{ tasks: TaskSummary[]; total: number }>( + "/tasks/", + { params: { assignee_id: username, limit: 100 } } + ); + // 排序:主干任务在前 + 创建时间升序(与 uni-app 完全一致) + const sorted = (data.tasks || []).sort((a, b) => { + const aIsMain = !a.parent_task_id || a.task_type !== "SPAWN"; + const bIsMain = !b.parent_task_id || b.task_type !== "SPAWN"; + if (aIsMain && !bIsMain) return -1; + if (!aIsMain && bIsMain) return 1; + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + }); + setTasks(sorted); + } catch { + setTasks([]); + } finally { + setLoading(false); + } + } + + // ---- 按 Tab 筛选 ---- + const filtered = useMemo(() => { + if (tab === "all") return tasks; + return tasks.filter((t) => t.status === tab); + }, [tasks, tab]); + + // ---- 各 Tab 计数 ---- + function countBy(key: TabKey) { + if (key === "all") return tasks.length; + return tasks.filter((t) => t.status === key).length; + } + + function goDetail(task: TaskSummary) { + const sn = task.product_sn || task.product_id; + if (sn) navigate(`/scan?serial=${sn}`); + } + + // ========================================================== + // 渲染 + // ========================================================== + return (
+ {/* 标题 */}

我的任务

-

待处理和进行中的任务

- {/* 占位空状态 */} -
-
- - - + {/* Tab 栏 — 与 uni-app 样式一致:sticky、可横向滚动 */} +
+
+ {TABS.map((t) => ( + + ))}
-

暂无待办任务

+ + {/* 加载中 */} + {loading && ( +
+ 加载中... +
+ )} + + {/* 空状态 */} + {!loading && filtered.length === 0 && ( +
+ 📋 + + {tab === "all" ? "暂无待办任务" : "无此状态任务"} + +
+ )} + + {/* 任务卡片列表 */} + {!loading && + filtered.map((task) => ( +
goDetail(task)} + className="mb-2.5 cursor-pointer rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-shadow hover:shadow-md" + > + {/* Row 1: 类型标签 + 任务名 | 状态 */} +
+
+ {/* 主干/协助 标签 — 与 uni-app tag-badge 一致 */} + + {task.task_type === "SPAWN" ? "协助" : "主干"} + + + {task.task_name} + +
+ + {STATUS_LABEL[task.status] || task.status} + +
+ + {/* Row 2: 身份证 + 物料 */} +
+ 身份证: {task.product_sn || "—"} + 物料: {task.product_material || "—"} +
+ + {/* Row 3: 创建时间 */} +
+ 创建: {formatTime(task.created_at)} +
+
+ ))}
); } diff --git a/frontend/src/pages/NotificationsPage.tsx b/frontend/src/pages/NotificationsPage.tsx index 5b8cdb5..75ddb09 100644 --- a/frontend/src/pages/NotificationsPage.tsx +++ b/frontend/src/pages/NotificationsPage.tsx @@ -1,29 +1,164 @@ -/** 消息 — 通知推送 */ -export default function NotificationsPage() { - return ( -
-

消息通知

-

任务流转和系统通知

+/** 消息通知 — 1:1 复刻 uni-app pages/notify/index.vue + 真实 API */ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; +import { + getNotifications, + markNotificationRead, + type NotificationItem, +} from "../services/notificationApi"; - {/* 占位空状态 */} -
-
- - - -
-

暂无新消息

+// ============================================================ +// 类型常量 +// ============================================================ + +const TYPE_CONFIG: Record = { + TRANSFER: { icon: "🟢", title: "新任务派发" }, + REJECT: { icon: "🔴", title: "品质驳回提醒" }, +}; + +function formatTime(t: string) { + if (!t) return ""; + const d = new Date(t); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// ============================================================ +// Page 组件 +// ============================================================ + +export default function NotificationsPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchNotifications(); + }, [user]); // eslint-disable-line react-hooks/exhaustive-deps + + async function fetchNotifications() { + const userId = user?.username || user?.id || ""; + if (!userId) { + setLoading(false); + return; + } + setLoading(true); + try { + const res = await getNotifications(userId); + setNotifications(res.notifications || []); + } catch { + setNotifications([]); + } finally { + setLoading(false); + } + } + + async function handleCardTap(item: NotificationItem) { + // 标记已读 + if (!item.is_read) { + try { + await markNotificationRead(item.id); + setNotifications((prev) => + prev.map((n) => (n.id === item.id ? { ...n, is_read: true } : n)) + ); + } catch { + // 静默 + } + } + + // 跳转详情页 + if (item.task_id) { + navigate(`/scan?taskId=${item.task_id}`); + } + } + + // ========================================================== + // 渲染 + // ========================================================== + + return ( +
+ {/* 标题区 — 与 uni-app 完全一致 */} +
+

消息通知

+

任务流转和系统通知

+ + {/* 加载中 */} + {loading && ( +
+ 加载中... +
+ )} + + {/* 空状态 */} + {!loading && notifications.length === 0 && ( +
+ 🔔 + 暂无新消息 +
+ )} + + {/* 通知列表 */} + {!loading && notifications.length > 0 && ( +
+ {notifications.map((item) => { + const cfg = TYPE_CONFIG[item.type] || { + icon: "📌", + title: "系统通知", + }; + return ( +
handleCardTap(item)} + className={`flex cursor-pointer items-start gap-2.5 rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-all active:scale-[0.98] ${ + !item.is_read + ? "border-l-[3px] border-l-blue-600 shadow-[0_1px_6px_rgba(37,99,235,0.1)]" + : "" + }`} + > + {/* 左侧:未读红点 + 类型图标 */} +
+ {!item.is_read && ( + + )} + + {cfg.icon} + +
+ + {/* 右侧:内容 */} +
+
+ + {cfg.title} + + + {formatTime(item.created_at)} + +
+

+ {item.content} +

+
+ + +
+ ); + })} +
+ )}
); } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index b7e75c3..474ba21 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,35 +1,71 @@ -/** 我的 — 个人中心 */ +/** 我的 — 1:1 复刻 uni-app pages/profile/index.vue + 真实用户数据绑定 */ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; +import { LogOut } from "lucide-react"; + export default function ProfilePage() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + // ---- 从 AuthContext 派生真实数据 ---- + const displayName = user?.display_name || user?.username || "未知用户"; + const avatarChar = displayName.charAt(0); + const roleLabel = + user?.role === "SUPER_ADMIN" + ? "超级管理员" + : user?.role || "操作员"; + + function handleLogout() { + logout(); + navigate("/admin/login", { replace: true }); + } + return ( -
- {/* 用户信息卡片 */} -
-
- 张 +
+ {/* 用户卡片 — 与 uni-app user-card 完全一致 */} +
+ {/* 头像 — 取真实用户名的第一个字 */} +
+ {avatarChar}
-

张三

-

操作员

+ + {displayName} + + {roleLabel}
- - - +
- {/* 菜单列表占位 */} -
- {["工作统计", "设置", "帮助与反馈", "关于"].map((item) => ( + {/* 菜单列表 — 与 uni-app menu-card 完全一致 */} +
+ {["工作统计", "设置", "帮助与反馈", "关于"].map((item, i) => (
{item} - - - +
))} + + {/* 退出登录 — 红色高亮 */} +
+ + {/* 版本号 — 与 uni-app .version 一致 */} +

生产流转 v1.0.0

); } diff --git a/frontend/src/pages/ScanPage.tsx b/frontend/src/pages/ScanPage.tsx index 0508629..f59b8f0 100644 --- a/frontend/src/pages/ScanPage.tsx +++ b/frontend/src/pages/ScanPage.tsx @@ -1,12 +1,14 @@ /** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */ -import { useState, useCallback } from "react"; -import { QrCode } from "lucide-react"; +import { useState, useCallback, Suspense, lazy } from "react"; +import { QrCode, Loader2 } from "lucide-react"; import { scanProduct } from "../services/productApi"; import type { ProductScanResponse } from "../types/api"; -import CameraScanner from "../components/scan/CameraScanner"; import ManualInput from "../components/scan/ManualInput"; import QueryResult from "../components/scan/QueryResult"; +// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载 +const CameraScanner = lazy(() => import("../components/scan/CameraScanner")); + export default function ScanPage() { const [cameraActive, setCameraActive] = useState(false); const [cameraError, setCameraError] = useState(null); @@ -18,7 +20,7 @@ export default function ScanPage() { /** 通用查询 */ const doQuery = useCallback(async (sn: string) => { - if (sn.length < 8) { setError("序列号至少需要 8 位"); return; } + if (sn.length < 8) { setError("产品身份证至少需要 8 位"); return; } setSerialNumber(sn); setLastScanned(sn); setLoading(true); @@ -37,7 +39,7 @@ export default function ScanPage() { } }, []); - /** 扫码回调:提取纯序列号 */ + /** 扫码回调:提取纯产品身份证 */ const handleScan = useCallback( (decodedText: string) => { setCameraError(null); @@ -53,15 +55,23 @@ export default function ScanPage() {

扫码干活

- {/* 摄像头扫码 */} + {/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
- + + +
+ } + > + +
{/* 手动输入 */} diff --git a/frontend/src/pages/admin/AdminProductsPage.tsx b/frontend/src/pages/admin/AdminProductsPage.tsx index ac50e35..9ffef73 100644 --- a/frontend/src/pages/admin/AdminProductsPage.tsx +++ b/frontend/src/pages/admin/AdminProductsPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import api from "../../services/api"; import type { ProductResponse } from "../../types/admin"; import CreateProductDialog from "./CreateProductDialog"; @@ -26,6 +27,35 @@ export default function AdminProductsPage() { const [printCopies, setPrintCopies] = useState(1); const [printing, setPrinting] = useState(false); + // 🚀 虚拟滚动:响应式列数 + 行虚拟化 + const scrollRef = useRef(null); + const [columns, setColumns] = useState(5); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const calc = () => { + const w = el.clientWidth; + if (w >= 1280) setColumns(6); + else if (w >= 1024) setColumns(5); + else if (w >= 768) setColumns(4); + else if (w >= 640) setColumns(3); + else setColumns(2); + }; + calc(); + const ro = new ResizeObserver(calc); + ro.observe(el); + return () => ro.disconnect(); + }, [products.length > 0]); + + const rowCount = Math.ceil(products.length / columns); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => 320, + overscan: 2, + }); + async function loadProducts() { setLoading(true); setError(null); @@ -124,7 +154,7 @@ export default function AdminProductsPage() {

产品管理

- 查看所有产品序列号并生成二维码用于打印标签 + 查看所有产品身份证并生成二维码用于打印标签

@@ -176,38 +206,74 @@ export default function AdminProductsPage() { )} {!loading && products.length > 0 && ( -
- {products.map((p) => ( -
- {/* 二维码 */} - {`QR-${p.serial_number}`} +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const start = virtualRow.index * columns; + const rowProducts = products.slice(start, start + columns); + return ( +
+ {/* 动态 inline grid 列数(Tailwind 不支持动态 columns) */} +
+ {rowProducts.map((p) => ( +
+ {/* 二维码 */} + {`QR-${p.serial_number}`} - {/* 序列号 */} -

- {p.serial_number} -

-

- {p.order_no ?? "—"} -

+ {/* 产品身份证 */} +

+ {p.serial_number} +

+

+ {p.order_no ?? "—"} +

- {/* 打印按钮 */} - -
- ))} + {/* 打印按钮 */} + +
+ ))} +
+
+ ); + })} +
)} diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index 9aa77dd..a5fa5b8 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -1,5 +1,474 @@ -import TaskTreeViewer from "../../components/TaskTree/TaskTreeViewer"; +/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { + Search, Loader2, Package, ChevronDown, ChevronRight, + Warehouse, GitBranch, X, +} from "lucide-react"; +import api from "../../services/api"; +import { scanProduct } from "../../services/productApi"; +import { + receiveTask, rejectTask, transferTask, +} from "../../services/taskApi"; +import { TaskFlowView } from "../../components/TaskTree/TaskFlowView"; +import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer"; +import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer"; +import type { ProductResponse } from "../../types/admin"; +import type { ProductScanResponse, TaskResponse } from "../../types/api"; +import { useToast } from "../../components/ui/Toast"; +import { getStatusConfig } from "../../constants/task"; + +const STATUS_TABS = [ + { key: "", label: "全部" }, + { key: "PENDING", label: "待接收" }, + { key: "WIP", label: "进行中" }, + { key: "COMPLETED", label: "已完成" }, + { key: "ARCHIVED", label: "已入库" }, +]; + +interface OrderGroup { + orderNo: string; + products: ProductResponse[]; + allInWarehouse: boolean; +} export default function AdminTasksPage() { - return ; + const { toast } = useToast(); + + // 搜索 & 筛选 + const [keyword, setKeyword] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 手风琴展开状态 + const [expandedOrders, setExpandedOrders] = useState>(new Set()); + + // 任务树展开 + const [expandedProducts, setExpandedProducts] = useState>(new Set()); + const [taskTrees, setTaskTrees] = useState>({}); + const [treeLoading, setTreeLoading] = useState>({}); + + // 弹窗 + const [modalTarget, setModalTarget] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // ---- 加载产品列表 ---- + async function loadProducts(kw: string, st: string) { + setLoading(true); + setError(null); + try { + const params: Record = { limit: 1000 }; + if (kw.trim()) params.keyword = kw.trim(); + if (st) params.status = st; + const { data } = await api.get("/products/", { params }); + setProducts(data); + } catch { + setError("加载产品列表失败,请检查后端服务"); + } finally { + setLoading(false); + } + } + + // 首次加载 + useEffect(() => { + loadProducts(keyword, statusFilter); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // 🚀 状态筛选 Tab 变化时自动重新查询 + useEffect(() => { + loadProducts(keyword, statusFilter); + }, [statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps + + function handleSearch(e?: React.FormEvent) { + e?.preventDefault(); + setExpandedOrders(new Set()); + setExpandedProducts(new Set()); + setTaskTrees({}); + loadProducts(keyword, statusFilter); + } + + // ---- 按订单分组 ---- + const orderGroups = useMemo(() => { + const map = new Map(); + for (const p of products) { + const key = p.order_no || "未绑定订单"; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(p); + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([orderNo, prods]) => ({ + orderNo, + products: prods, + allInWarehouse: prods.every( + (p) => p.current_location_id === "virtual_warehouse" + ), + })); + }, [products]); + + // ---- 手风琴切换 ---- + function toggleOrder(orderNo: string) { + setExpandedOrders((prev) => { + const next = new Set(prev); + if (next.has(orderNo)) next.delete(orderNo); + else next.add(orderNo); + return next; + }); + } + + // ---- 任务树展开/收起 ---- + async function toggleProductTree(serialNumber: string) { + setExpandedProducts((prev) => { + const next = new Set(prev); + if (next.has(serialNumber)) { + next.delete(serialNumber); + return next; + } + // 加载任务树 + next.add(serialNumber); + if (!taskTrees[serialNumber]) { + setTreeLoading((s) => ({ ...s, [serialNumber]: true })); + scanProduct(serialNumber) + .then((result) => { + setTaskTrees((s) => ({ ...s, [serialNumber]: result })); + }) + .catch((err: any) => { + const msg = + err?.response?.data?.detail ?? err?.message ?? "加载任务树失败"; + toast(msg, "error"); + setExpandedProducts((prev2) => { + const n2 = new Set(prev2); + n2.delete(serialNumber); + return n2; + }); + }) + .finally(() => { + setTreeLoading((s) => ({ ...s, [serialNumber]: false })); + }); + } + return next; + }); + } + + // ---- 任务操作 ---- + const refreshProductTree = useCallback(async (serialNumber: string) => { + try { + const result = await scanProduct(serialNumber); + setTaskTrees((s) => ({ ...s, [serialNumber]: result })); + } catch { /* 静默 */ } + }, []); + + async function handleReceive() { + if (!modalTarget) return; + setSubmitting(true); + try { + await receiveTask(modalTarget.task.id); + toast("任务已接收", "success"); + const sn = modalTarget.task.product_sn || ""; + setModalTarget(null); + if (sn) await refreshProductTree(sn); + await loadProducts(keyword, statusFilter); + } catch (err: any) { + toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error"); + } finally { + setSubmitting(false); + } + } + + async function handleReject(reason: string) { + if (!modalTarget) return; + setSubmitting(true); + try { + await rejectTask(modalTarget.task.id, reason); + toast("任务已驳回", "success"); + const sn = modalTarget.task.product_sn || ""; + setModalTarget(null); + if (sn) await refreshProductTree(sn); + await loadProducts(keyword, statusFilter); + } catch (err: any) { + toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error"); + } finally { + setSubmitting(false); + } + } + + async function handleTransfer( + nextTaskName: string, + assignees: string[], + note: string, + ) { + if (!modalTarget) return; + setSubmitting(true); + try { + const result = await transferTask( + modalTarget.task.id, assignees, nextTaskName, note || undefined, + ); + toast(result.message, "success"); + const sn = modalTarget.task.product_sn || ""; + setModalTarget(null); + if (sn) await refreshProductTree(sn); + await loadProducts(keyword, statusFilter); + } catch (err: any) { + toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error"); + } finally { + setSubmitting(false); + } + } + + // ---- 渲染 ---- + const modalTask = modalTarget?.task ?? null; + + return ( +
+ {/* ---- 标题 ---- */} +
+

任务全景 Dashboard

+

+ 按订单聚合查看产品流转状态,支持多维搜索与状态筛选 +

+
+ + {/* ---- 搜索 + 筛选 ---- */} +
+ +
+ + setKeyword(e.target.value)} + placeholder="搜索产品身份证、订单号、规格型号..." + className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" + /> +
+ + + + {/* 状态筛选 Tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+
+ + {/* ---- 错误 ---- */} + {error && ( +
+ {error} +
+ )} + + {/* ---- 加载中 ---- */} + {loading && ( +
+ +
+ )} + + {/* ---- 空态 ---- */} + {!loading && products.length === 0 && !error && ( +
+ +

暂无产品数据

+

创建产品并绑定订单后,在此查看流转状态

+
+ )} + + {/* ---- 订单手风琴列表 ---- */} + {!loading && orderGroups.length > 0 && ( +
+ {orderGroups.map((group) => { + const isOpen = expandedOrders.has(group.orderNo); + return ( +
+ {/* 订单头部 */} + + + {/* 订单展开内容 */} + {isOpen && ( +
+ {/* 表头 */} +
+
产品身份证
+
规格型号
+
宏观状态
+
任务状态
+
当前位置
+
创建时间
+
操作
+
+ + {/* 产品行 */} + {group.products.map((p) => { + const productExpanded = expandedProducts.has(p.serial_number); + const isTreeLoading = treeLoading[p.serial_number]; + const tree = taskTrees[p.serial_number]; + const statusCfg = getStatusConfig(p.status); + + return ( +
+
+
+ {p.serial_number} +
+
+ {p.spec_model || p.material_name || p.material_id || "—"} +
+
+ + {p.overall_status || "—"} + +
+
+ + {statusCfg.label} + +
+
+ {p.current_location_id === "virtual_warehouse" ? ( + + 🏭 仓库 + + ) : ( + p.current_location_id ?? "—" + )} +
+
+ {new Date(p.created_at).toLocaleDateString("zh-CN")} +
+
+ +
+
+ + {/* 展开的流转树 — 卡片堆叠视图 */} + {productExpanded && tree && ( +
+

+ + 流转卡片 — {p.serial_number} +

+ {tree.task_tree && tree.task_tree.length > 0 ? ( + + ) : ( +
+ +

暂无流转记录

+

产品刚创建,尚未分配生产任务

+
+ )} +
+ )} + {productExpanded && isTreeLoading && ( +
+ +

加载流转树...

+
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} + + {/* ---- 弹窗 ---- */} + setModalTarget(null)} + onConfirm={handleReceive} + /> + setModalTarget(null)} + onSubmit={handleReject} + /> + setModalTarget(null)} + onSubmit={handleTransfer} + /> +
+ ); } diff --git a/frontend/src/pages/admin/CreateProductDialog.tsx b/frontend/src/pages/admin/CreateProductDialog.tsx index a908fb9..0c5f152 100644 --- a/frontend/src/pages/admin/CreateProductDialog.tsx +++ b/frontend/src/pages/admin/CreateProductDialog.tsx @@ -453,11 +453,11 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
{/* ================================================================ */} - {/* 系统唯一 ID(只读) */} + {/* 产品身份证 — 系统自动生成 16 位 HEX(不可编辑) */} {/* ================================================================ */}
- {/* 产品序列号(选填) */} + {/* 产品序列号 — 用户自定义录入(选填) */}