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);
}