feat(frontend): 实现任务全景树组件与三大操作弹窗
TaskTreeViewer (components/TaskTree/): - 搜索栏: 输入 16 位序列号 → getTaskTree() → 递归渲染产品任务树 - 状态标签: PENDING(黄)/WIP(蓝)/COMPLETED(绿)/REJECTED(红)/ARCHIVED(灰) - 返工警告: is_rework → 红色脉冲 ⚠返工 标签 + 红色 ring 边框 - 裂变标识: child_tasks>1 → 紫色 裂变×N 标签 + 树形连接线 - 产品摘要栏: 序列号/订单/当前位置(virtual_warehouse→🏭仓库)/任务总数 三大操作弹窗 (替换 alert 占位符): - ReceiveConfirmModal: 二次确认 → receiveTask() → Toast + 刷新树 - RejectModal: 驳回原因必填(≤500字) → rejectTask() → 自动返工提示 - TransferModal: 工序名称 + 多接收人标签输入(Enter添加/X删除) + 🏭虚拟入库勾选 + 备注 + 实时裂变预览 → transferTask() 路由接入: - App.tsx: 包裹 ToastProvider, 新增 /admin/tasks 路由 - AdminLayout: 侧边栏新增「任务全景」菜单 (GitBranch 图标) - AdminTasksPage: 薄封装层
This commit is contained in:
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* 移动端 */}
|
||||
<Route path="/" element={<Navigate to="/scan" replace />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/scan" element={<ScanPage />} />
|
||||
<Route path="/tasks" element={<MyTasksPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* 移动端 */}
|
||||
<Route path="/" element={<Navigate to="/scan" replace />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/scan" element={<ScanPage />} />
|
||||
<Route path="/tasks" element={<MyTasksPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
{/* PC 管理端 */}
|
||||
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProductsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
{/* PC 管理端 */}
|
||||
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProductsPage />} />
|
||||
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
952
frontend/src/components/TaskTree/TaskTreeViewer.tsx
Normal file
952
frontend/src/components/TaskTree/TaskTreeViewer.tsx
Normal file
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* 弹窗 */}
|
||||
<div className="relative z-10 mx-4 w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-gray-800">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 确认接收弹窗
|
||||
// ============================================================
|
||||
|
||||
function ReceiveConfirmModal({
|
||||
open,
|
||||
task,
|
||||
submitting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: TaskResponse | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="确认接收任务">
|
||||
<div className="mb-6 rounded-lg bg-blue-50 px-3 py-2.5 text-sm text-blue-700">
|
||||
<p className="font-medium">{task.task_name}</p>
|
||||
<p className="mt-0.5 text-xs text-blue-500">
|
||||
当前状态: {getStatusConfig(task.status).label}
|
||||
{" → "}
|
||||
<span className="font-semibold">进行中 (WIP)</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="mb-6 text-sm text-gray-500">
|
||||
确认接收后,该任务将标记为"进行中",系统将记录接收时间。
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认接收
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 品质驳回弹窗
|
||||
// ============================================================
|
||||
|
||||
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 (
|
||||
<Modal open={open} onClose={handleClose} title="品质驳回">
|
||||
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<p className="font-medium">{task.task_name}</p>
|
||||
<p className="mt-0.5 text-xs text-red-500">
|
||||
⚠ 驳回后将自动生成返工任务,分配给上一道工序负责人
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
驳回原因 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="请详细说明品质驳回的原因(必填)"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="mb-4 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">{reason.length}/500</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !reason.trim()}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认驳回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 完工裂变转交弹窗
|
||||
// ============================================================
|
||||
|
||||
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<string[]>([]);
|
||||
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 (
|
||||
<Modal open={open} onClose={handleClose} title="完工转交 — 裂变到下道工序">
|
||||
<div className="mb-4 rounded-lg bg-green-50 px-3 py-2.5 text-sm text-green-700">
|
||||
<p className="font-medium">{task.task_name}</p>
|
||||
<p className="mt-0.5 text-xs text-green-500">
|
||||
完成任务并批量创建下一道工序任务,支持多路裂变分支
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 下道工序名称 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
下一道工序名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nextTaskName}
|
||||
onChange={(e) => setNextTaskName(e.target.value)}
|
||||
placeholder="如:组装、质检、包装"
|
||||
maxLength={200}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 接收人(多选) */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-sm font-medium text-gray-700">
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
下一道工序接收人 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
{/* 已添加的接收人标签 */}
|
||||
{allAssignees.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{allAssignees.map((a) => (
|
||||
<span
|
||||
key={a}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
a === "virtual_warehouse"
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{a === "virtual_warehouse" ? (
|
||||
<Warehouse className="h-3 w-3" />
|
||||
) : null}
|
||||
{a === "virtual_warehouse" ? "仓库" : a}
|
||||
{a !== "virtual_warehouse" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAssignee(a)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-200"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入 + 添加按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={handleAssigneeKeyDown}
|
||||
placeholder="输入接收人 ID,按 Enter 添加"
|
||||
maxLength={64}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 虚拟入库 */}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useWarehouse}
|
||||
onChange={(e) => setUseWarehouse(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<Warehouse className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-700">
|
||||
同时入库(产品 current_location → virtual_warehouse)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
交接备注 <span className="text-gray-400 font-normal">(可选)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="转交附言、注意事项等"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
{allAssignees.length > 0 && nextTaskName.trim() && (
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500">
|
||||
<p className="font-medium text-gray-600">裂变预览:</p>
|
||||
<p className="mt-1">
|
||||
将创建 <span className="font-semibold text-gray-700">
|
||||
{allAssignees.length}
|
||||
</span>{" "}
|
||||
个「
|
||||
<span className="font-semibold text-gray-700">
|
||||
{nextTaskName.trim()}
|
||||
</span>
|
||||
」任务
|
||||
{allAssignees.length > 1 && (
|
||||
<span className="text-purple-600">
|
||||
{" "}
|
||||
— 触发裂变,父任务为当前任务
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
接收人:{" "}
|
||||
{allAssignees
|
||||
.map((a) => (a === "virtual_warehouse" ? "仓库" : a))
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
!nextTaskName.trim() ||
|
||||
allAssignees.length === 0
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认转交
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单个任务节点卡片
|
||||
// ============================================================
|
||||
|
||||
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 (
|
||||
<div className="relative">
|
||||
{/* 树形连接线 */}
|
||||
{task.child_tasks.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-full z-0 w-px bg-gray-200"
|
||||
style={{ height: "calc(100% - 2rem)" }}
|
||||
/>
|
||||
{task.child_tasks.length > 1 && (
|
||||
<div
|
||||
className="absolute left-4 z-0 h-px bg-gray-200"
|
||||
style={{
|
||||
top: "calc(100% + 1rem)",
|
||||
width: "calc(50% - 1rem)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 卡片本体 */}
|
||||
<div
|
||||
className={`relative z-10 mb-1 rounded-lg border bg-white px-3 py-2.5 shadow-sm transition-shadow hover:shadow-md ${ring} ${
|
||||
task.is_rework ? "ring-2 ring-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* 左侧:任务名 + 标签 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{task.is_rework && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-bold text-white animate-pulse">
|
||||
⚠ 返工
|
||||
</span>
|
||||
)}
|
||||
{task.child_tasks.length > 1 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
|
||||
<GitBranch className="h-2.5 w-2.5" />
|
||||
裂变×{task.child_tasks.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm font-semibold text-gray-800">
|
||||
{task.task_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
|
||||
{task.assignee_id && <span>负责人: {task.assignee_id}</span>}
|
||||
{task.received_at && (
|
||||
<span>
|
||||
接收: {new Date(task.received_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{task.completed_at && (
|
||||
<span>
|
||||
完成:{" "}
|
||||
{new Date(task.completed_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.reject_reason && (
|
||||
<p className="mt-1 text-[11px] text-red-500">
|
||||
驳回原因: {task.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:状态标签 + 操作按钮 */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${bg} ${text}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{task.status === TASK_STATUS.PENDING && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="rounded border border-blue-200 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "receive" })}
|
||||
>
|
||||
接收
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "reject" })}
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "transfer" })}
|
||||
>
|
||||
转交
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{task.status === TASK_STATUS.WIP && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "reject" })}
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "transfer" })}
|
||||
>
|
||||
转交
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 递归渲染子任务 */}
|
||||
{task.child_tasks.length > 0 && (
|
||||
<div className="ml-8 border-l-2 border-gray-100 pl-4 pt-1">
|
||||
{task.child_tasks.map((child, idx) => (
|
||||
<TaskNodeCard
|
||||
key={child.id}
|
||||
task={child}
|
||||
isLast={idx === task.child_tasks.length - 1}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主容器组件
|
||||
// ============================================================
|
||||
|
||||
export default function TaskTreeViewer() {
|
||||
const { toast } = useToast();
|
||||
|
||||
// 搜索状态
|
||||
const [serial, setSerial] = useState("");
|
||||
const [product, setProduct] = useState<ProductScanResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 弹窗状态
|
||||
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(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 (
|
||||
<div>
|
||||
{/* ---- 搜索栏 ---- */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">任务全景树</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
输入 16 位产品序列号,查看完整任务流转十字矩阵树状图
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={serial}
|
||||
onChange={(e) => setSerial(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
查询
|
||||
</button>
|
||||
{product && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSearch()}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-500 transition-colors hover:bg-gray-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ---- 错误 ---- */}
|
||||
{error && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 加载中 ---- */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 产品信息摘要 ---- */}
|
||||
{product && !loading && (
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<span className="text-xs text-gray-400">产品序列号</span>
|
||||
<p className="font-mono text-base font-bold tracking-widest text-gray-800">
|
||||
{product.serial_number}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden h-8 w-px bg-gray-200 sm:block" />
|
||||
<div>
|
||||
<span className="text-xs text-gray-400">订单编号</span>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{product.order_no || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden h-8 w-px bg-gray-200 sm:block" />
|
||||
<div>
|
||||
<span className="text-xs text-gray-400">当前位置</span>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{product.current_location_id === "virtual_warehouse" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-purple-50 px-1.5 py-0.5 text-xs font-medium text-purple-600">
|
||||
🏭 仓库
|
||||
</span>
|
||||
) : (
|
||||
product.current_location_id ?? "—"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden h-8 w-px bg-gray-200 sm:block" />
|
||||
<div>
|
||||
<span className="text-xs text-gray-400">任务总数</span>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{product.top_level_tasks?.length ?? 0} 顶层
|
||||
{product.task_tree?.length
|
||||
? ` · ${countAllTasks(product.task_tree)} 总计`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 任务树 ---- */}
|
||||
{product && !loading && (
|
||||
<>
|
||||
{product.task_tree && product.task_tree.length > 0 ? (
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-gray-500">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
任务流转树状图
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{product.task_tree.map((task, idx) => (
|
||||
<TaskNodeCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isLast={idx === product.task_tree!.length - 1}
|
||||
onAction={setModalTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">
|
||||
<GitBranch className="mb-3 h-10 w-10" />
|
||||
<p className="text-sm">该产品暂无关联任务</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 弹窗 ---- */}
|
||||
<ReceiveConfirmModal
|
||||
open={modalTarget?.action === "receive"}
|
||||
task={modalTask}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalTarget(null)}
|
||||
onConfirm={handleReceive}
|
||||
/>
|
||||
<RejectModal
|
||||
open={modalTarget?.action === "reject"}
|
||||
task={modalTask}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalTarget(null)}
|
||||
onSubmit={handleReject}
|
||||
/>
|
||||
<TransferModal
|
||||
open={modalTarget?.action === "transfer"}
|
||||
task={modalTask}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalTarget(null)}
|
||||
onSubmit={handleTransfer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数:递归统计任务总数
|
||||
// ============================================================
|
||||
|
||||
function countAllTasks(tasks: TaskResponse[]): number {
|
||||
return tasks.reduce((sum, t) => sum + 1 + countAllTasks(t.child_tasks), 0);
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
5
frontend/src/pages/admin/AdminTasksPage.tsx
Normal file
5
frontend/src/pages/admin/AdminTasksPage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import TaskTreeViewer from "../../components/TaskTree/TaskTreeViewer";
|
||||
|
||||
export default function AdminTasksPage() {
|
||||
return <TaskTreeViewer />;
|
||||
}
|
||||
Reference in New Issue
Block a user