Files
track/frontend/src/components/TaskTree/TaskTreeViewer.tsx

751 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useMemo, memo } from "react";
import {
Search,
Loader2,
AlertCircle,
GitBranch,
RefreshCw,
X,
Plus,
Warehouse,
UserPlus,
} from "lucide-react";
import TaskFlowView from "./TaskFlowView";
import {
getTaskTree,
receiveTask,
rejectTask,
transferTask,
} from "../../services/taskApi";
import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
// ============================================================
// 通用 Modal 容器
// ============================================================
const Modal = memo(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>
);
});
// ============================================================
// 确认接收弹窗
// ============================================================
export const ReceiveConfirmModal = memo(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>
);
});
// ============================================================
// 品质驳回弹窗
// ============================================================
export const RejectModal = memo(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>
);
});
// ============================================================
// 完工裂变转交弹窗
// ============================================================
export const TransferModal = memo(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>
);
});
// ============================================================
// 操作弹窗目标类型
// ============================================================
export interface ModalTarget {
task: TaskResponse;
action: "receive" | "reject" | "transfer";
}
// ============================================================
// 主容器组件
// ============================================================
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);
}
}
// ---- 渲染 ----
// 🚀 缓存递归计算 — 仅在 task_tree 变化时重新计算
const totalTaskCount = useMemo(
() => (product?.task_tree ? countAllTasks(product.task_tree) : 0),
[product?.task_tree],
);
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
? ` · ${totalTaskCount} 总计`
: ""}
</p>
</div>
</div>
</div>
)}
{/* ---- 任务流转分支/卡片视图 ---- */}
{product && !loading && (
<>
{product.task_tree && product.task_tree.length > 0 ? (
<div className="rounded-xl bg-white p-6 shadow-sm overflow-hidden">
<TaskFlowView
tasks={product.task_tree}
onAction={setModalTarget}
/>
</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);
}