diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 819c89e..52111f5 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import { ToastProvider } from "./components/ui/Toast";
import AppLayout from "./components/layout/AppLayout";
import ScanPage from "./pages/ScanPage";
import MyTasksPage from "./pages/MyTasksPage";
@@ -9,27 +10,31 @@ import ProfilePage from "./pages/ProfilePage";
import AdminLayout from "./components/layout/AdminLayout";
import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminProductsPage from "./pages/admin/AdminProductsPage";
+import AdminTasksPage from "./pages/admin/AdminTasksPage";
export default function App() {
return (
-
-
- {/* 移动端 */}
- } />
- }>
- } />
- } />
- } />
- } />
-
+
+
+
+ {/* 移动端 */}
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
- {/* PC 管理端 */}
- } />
- }>
- } />
- } />
-
-
-
+ {/* PC 管理端 */}
+ } />
+ }>
+ } />
+ } />
+ } />
+
+
+
+
);
}
diff --git a/frontend/src/components/TaskTree/TaskTreeViewer.tsx b/frontend/src/components/TaskTree/TaskTreeViewer.tsx
new file mode 100644
index 0000000..f0cba09
--- /dev/null
+++ b/frontend/src/components/TaskTree/TaskTreeViewer.tsx
@@ -0,0 +1,952 @@
+import { useState, useCallback } from "react";
+import {
+ Search,
+ Loader2,
+ AlertCircle,
+ GitBranch,
+ RefreshCw,
+ X,
+ Plus,
+ Warehouse,
+ UserPlus,
+} from "lucide-react";
+import {
+ getTaskTree,
+ receiveTask,
+ rejectTask,
+ 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,
+ }
+ );
+}
+
+// ============================================================
+// 通用 Modal 容器
+// ============================================================
+
+function Modal({
+ open,
+ onClose,
+ title,
+ children,
+}: {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
+}) {
+ if (!open) return null;
+
+ return (
+
+ {/* 遮罩 */}
+
+ {/* 弹窗 */}
+
+
+
{title}
+
+
+ {children}
+
+
+ );
+}
+
+// ============================================================
+// 确认接收弹窗
+// ============================================================
+
+function ReceiveConfirmModal({
+ open,
+ task,
+ submitting,
+ onClose,
+ onConfirm,
+}: {
+ open: boolean;
+ task: TaskResponse | null;
+ submitting: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+}) {
+ if (!task) return null;
+
+ return (
+
+
+
{task.task_name}
+
+ 当前状态: {getStatusConfig(task.status).label}
+ {" → "}
+ 进行中 (WIP)
+
+
+
+ 确认接收后,该任务将标记为"进行中",系统将记录接收时间。
+
+
+
+
+
+
+ );
+}
+
+// ============================================================
+// 品质驳回弹窗
+// ============================================================
+
+function RejectModal({
+ open,
+ task,
+ submitting,
+ onClose,
+ onSubmit,
+}: {
+ open: boolean;
+ task: TaskResponse | null;
+ submitting: boolean;
+ onClose: () => void;
+ onSubmit: (reason: string) => void;
+}) {
+ const [reason, setReason] = useState("");
+
+ if (!task) return null;
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const trimmed = reason.trim();
+ if (!trimmed) return;
+ onSubmit(trimmed);
+ setReason("");
+ }
+
+ function handleClose() {
+ setReason("");
+ onClose();
+ }
+
+ return (
+
+
+
{task.task_name}
+
+ ⚠ 驳回后将自动生成返工任务,分配给上一道工序负责人
+
+
+
+
+ );
+}
+
+// ============================================================
+// 完工裂变转交弹窗
+// ============================================================
+
+function TransferModal({
+ open,
+ task,
+ submitting,
+ onClose,
+ onSubmit,
+}: {
+ open: boolean;
+ task: TaskResponse | null;
+ submitting: boolean;
+ onClose: () => void;
+ onSubmit: (nextTaskName: string, assignees: string[], note: string) => void;
+}) {
+ const [nextTaskName, setNextTaskName] = useState("");
+ const [assigneeInput, setAssigneeInput] = useState("");
+ const [assignees, setAssignees] = useState([]);
+ const [useWarehouse, setUseWarehouse] = useState(false);
+ const [note, setNote] = useState("");
+
+ if (!task) return null;
+
+ function handleAddAssignee() {
+ const trimmed = assigneeInput.trim();
+ if (!trimmed) return;
+ if (assignees.includes(trimmed)) {
+ setAssigneeInput("");
+ return;
+ }
+ setAssignees((prev) => [...prev, trimmed]);
+ setAssigneeInput("");
+ }
+
+ function handleRemoveAssignee(id: string) {
+ setAssignees((prev) => prev.filter((a) => a !== id));
+ }
+
+ function handleAssigneeKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddAssignee();
+ }
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const name = nextTaskName.trim();
+ if (!name) return;
+
+ const finalAssignees = [...assignees];
+ if (useWarehouse) {
+ finalAssignees.push("virtual_warehouse");
+ }
+ if (finalAssignees.length === 0) return;
+
+ onSubmit(name, finalAssignees, note.trim());
+ // 重置表单
+ setNextTaskName("");
+ setAssignees([]);
+ setAssigneeInput("");
+ setUseWarehouse(false);
+ setNote("");
+ }
+
+ function handleClose() {
+ setNextTaskName("");
+ setAssignees([]);
+ setAssigneeInput("");
+ setUseWarehouse(false);
+ setNote("");
+ onClose();
+ }
+
+ const allAssignees = [
+ ...assignees,
+ ...(useWarehouse ? ["virtual_warehouse"] : []),
+ ];
+
+ return (
+
+
+
{task.task_name}
+
+ 完成任务并批量创建下一道工序任务,支持多路裂变分支
+
+
+
+
+
+ );
+}
+
+// ============================================================
+// 单个任务节点卡片
+// ============================================================
+
+interface ModalTarget {
+ task: TaskResponse;
+ action: "receive" | "reject" | "transfer";
+}
+
+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) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ============================================================
+// 主容器组件
+// ============================================================
+
+export default function TaskTreeViewer() {
+ const { toast } = useToast();
+
+ // 搜索状态
+ const [serial, setSerial] = useState("");
+ const [product, setProduct] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 弹窗状态
+ const [modalTarget, setModalTarget] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ // ---- 数据刷新 ----
+ const refresh = useCallback(async () => {
+ const trimmed = serial.trim();
+ if (trimmed.length !== 16) return;
+ try {
+ const result = await getTaskTree(trimmed);
+ setProduct(result);
+ } catch {
+ // 静默失败 — 不影响当前展示
+ }
+ }, [serial]);
+
+ async function handleSearch(e?: React.FormEvent) {
+ e?.preventDefault();
+ const trimmed = serial.trim();
+ if (trimmed.length !== 16) {
+ setError("请输入 16 位产品序列号");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setProduct(null);
+
+ try {
+ const result = await getTaskTree(trimmed);
+ setProduct(result);
+ } catch (err: any) {
+ const msg =
+ err?.response?.status === 404
+ ? `未找到序列号 ${trimmed} 对应的产品`
+ : err?.response?.data?.detail ??
+ err?.message ??
+ "查询失败,请检查后端服务";
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // ---- 操作处理 ----
+
+ async function handleReceive() {
+ if (!modalTarget) return;
+ setSubmitting(true);
+ try {
+ await receiveTask(modalTarget.task.id);
+ toast("任务已接收 (PENDING → WIP)", "success");
+ setModalTarget(null);
+ await refresh();
+ } 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");
+ setModalTarget(null);
+ await refresh();
+ } 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");
+ setModalTarget(null);
+ await refresh();
+ } catch (err: any) {
+ toast(
+ err?.response?.data?.detail ?? err?.message ?? "转交失败",
+ "error"
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ // ---- 渲染 ----
+
+ const modalTask = modalTarget?.task ?? null;
+
+ return (
+
+ {/* ---- 搜索栏 ---- */}
+
+
任务全景树
+
+ 输入 16 位产品序列号,查看完整任务流转十字矩阵树状图
+
+
+
+
+
+ {/* ---- 错误 ---- */}
+ {error && (
+
+ )}
+
+ {/* ---- 加载中 ---- */}
+ {loading && (
+
+
+
+ )}
+
+ {/* ---- 产品信息摘要 ---- */}
+ {product && !loading && (
+
+
+
+
产品序列号
+
+ {product.serial_number}
+
+
+
+
+
订单编号
+
+ {product.order_no || "—"}
+
+
+
+
+
当前位置
+
+ {product.current_location_id === "virtual_warehouse" ? (
+
+ 🏭 仓库
+
+ ) : (
+ product.current_location_id ?? "—"
+ )}
+
+
+
+
+
任务总数
+
+ {product.top_level_tasks?.length ?? 0} 顶层
+ {product.task_tree?.length
+ ? ` · ${countAllTasks(product.task_tree)} 总计`
+ : ""}
+
+
+
+
+ )}
+
+ {/* ---- 任务树 ---- */}
+ {product && !loading && (
+ <>
+ {product.task_tree && product.task_tree.length > 0 ? (
+
+
+
+ 任务流转树状图
+
+
+ {product.task_tree.map((task, idx) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {/* ---- 弹窗 ---- */}
+
setModalTarget(null)}
+ onConfirm={handleReceive}
+ />
+ setModalTarget(null)}
+ onSubmit={handleReject}
+ />
+ setModalTarget(null)}
+ onSubmit={handleTransfer}
+ />
+
+ );
+}
+
+// ============================================================
+// 工具函数:递归统计任务总数
+// ============================================================
+
+function countAllTasks(tasks: TaskResponse[]): number {
+ return tasks.reduce((sum, t) => sum + 1 + countAllTasks(t.child_tasks), 0);
+}
diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx
index 18c7dd1..478e917 100644
--- a/frontend/src/components/layout/AdminLayout.tsx
+++ b/frontend/src/components/layout/AdminLayout.tsx
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
-import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone } from "lucide-react";
+import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch } from "lucide-react";
const MENU = [
{
@@ -14,6 +14,12 @@ const MENU = [
icon: Package,
description: "创建产品 · 生成二维码 · 打印标签",
},
+ {
+ title: "任务全景",
+ path: "/admin/tasks",
+ icon: GitBranch,
+ description: "序列号查任务树 · 裂变/返工可视化",
+ },
];
export default function AdminLayout() {
diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx
new file mode 100644
index 0000000..9aa77dd
--- /dev/null
+++ b/frontend/src/pages/admin/AdminTasksPage.tsx
@@ -0,0 +1,5 @@
+import TaskTreeViewer from "../../components/TaskTree/TaskTreeViewer";
+
+export default function AdminTasksPage() {
+ return ;
+}