Files
track/frontend/src/components/TaskTree/TaskTreeViewer.tsx
duxingchen e707f3673d feat: PC 端补齐直接完结通道
- taskApi.transferTask 增 finishDirectly 参数,按新 Schema 发送
  { next_tasks: [], finish_directly: true, note }
- TaskTransferPayload 支持 next_tasks / finish_directly,旧版单线字段降为可选
  (后端仍兼容 next_assignees / next_task_name,存量调用不受影响)
- 共享 TransferModal 新增「🏁 直接完结(无下游,不入库)」开关:
  与入库互斥,选中后下游字段禁用(保留输入便于反悔切回)、
  切换独立标题/提示/预览/提交按钮,不再套用「将创建 N 个任务」模板
- 表单重置改为「展开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交
- TaskTreeViewer 与 AdminTasksPage 两个入口的 handleTransfer 签名对齐
2026-09-17 14:08:30 +08:00

871 lines
29 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, useEffect, 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";
import ImageUploader from "../ui/ImageUploader";
import { extractErrorMessage } from "../../utils/errorMessage";
// ============================================================
// 通用 Modal 容器
// ============================================================
export const Modal = memo(function Modal({
open,
onClose,
title,
children,
widthClass = "max-w-md",
bodyClassName = "",
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
widthClass?: string;
bodyClassName?: string;
}) {
// 🚀 弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [open]);
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 ${widthClass} rounded-xl bg-white p-6 shadow-2xl ${bodyClassName}`}>
<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, images: string[]) => void;
}) {
const [reason, setReason] = useState("");
const [images, setImages] = useState<string[]>([]);
if (!task) return null;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = reason.trim();
if (!trimmed) return;
// 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交
onSubmit(trimmed, images);
setReason("");
setImages([]);
}
function handleClose() {
setReason("");
setImages([]);
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="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="mb-3 mt-1 text-right text-xs text-gray-400">{reason.length}/500</div>
<label className="mb-1 block text-sm font-medium text-gray-700">
<span className="text-xs font-normal text-gray-400"> 9 </span>
</label>
<ImageUploader
value={images}
onChange={setImages}
disabled={submitting}
/>
<div className="mt-4 flex items-center justify-end 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>
</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,
finishDirectly: boolean
) => void;
}) {
const [nextTaskName, setNextTaskName] = useState("");
const [assigneeInput, setAssigneeInput] = useState("");
const [assignees, setAssignees] = useState<string[]>([]);
const [useWarehouse, setUseWarehouse] = useState(false);
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
const [finishDirectly, setFinishDirectly] = useState(false);
const [note, setNote] = useState("");
// 🔄 每次打开都重置TransferModal 被 memo 后常驻挂载task 为 null 时只是 return null
// 状态会跨次打开残留,必须显式清零。
// ⚠️ 必须放在 `if (!task) return null` 之前 —— 否则是条件提前返回后再调 Hook
// 违反 Rules of Hookstask 从 null 变非 null 时 Hook 数量错位会直接崩。
useEffect(() => {
if (!open) return;
setNextTaskName("");
setAssigneeInput("");
setAssignees([]);
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}, [open]);
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();
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
if (!finishDirectly) {
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(), false);
} else {
onSubmit("", [], note.trim(), true);
}
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
// 统一改由下方的「打开时重置」负责。
}
function resetForm() {
setNextTaskName("");
setAssignees([]);
setAssigneeInput("");
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}
function handleClose() {
resetForm();
onClose();
}
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
const downstreamDisabled = finishDirectly;
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
function handleToggleFinishDirectly(checked: boolean) {
setFinishDirectly(checked);
if (checked) setUseWarehouse(false);
}
const allAssignees = [
...assignees,
...(useWarehouse ? ["virtual_warehouse"] : []),
];
return (
<Modal
open={open}
onClose={handleClose}
title={
finishDirectly
? "完工转交 — 🏁 直接完结(无下游,不入库)"
: "完工转交 — 裂变到下道工序"
}
>
<div
className={`mb-4 rounded-lg px-3 py-2.5 text-sm ${
finishDirectly
? "bg-amber-50 text-amber-700"
: "bg-green-50 text-green-700"
}`}
>
<p className="font-medium">{task.task_name}</p>
<p
className={`mt-0.5 text-xs ${
finishDirectly ? "text-amber-500" : "text-green-500"
}`}
>
{finishDirectly
? "结束本任务且不创建下游任务,产品状态保持原样"
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
</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}
disabled={downstreamDisabled}
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 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
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}
disabled={downstreamDisabled}
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 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
/>
<button
type="button"
onClick={handleAddAssignee}
disabled={!assigneeInput.trim() || downstreamDisabled}
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 select-none ${
downstreamDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"
}`}
>
<input
type="checkbox"
checked={useWarehouse}
onChange={(e) => setUseWarehouse(e.target.checked)}
disabled={downstreamDisabled}
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="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={finishDirectly}
onChange={(e) => handleToggleFinishDirectly(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-sm font-medium text-gray-700">
🏁
</span>
</label>
{finishDirectly && (
<p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
<span className="font-semibold"></span>
</p>
)}
</div>
{/* 备注 */}
<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>
{/* 预览 — 🏁 直接完结走独立文案,不能套用「将创建 N 个任务」模板 */}
{finishDirectly && (
<div className="rounded-lg bg-amber-50 px-3 py-2.5 text-xs text-amber-700">
<p className="font-medium">🏁 </p>
<p className="mt-1">
<span className="font-semibold">{task.task_name}</span>
<span className="font-semibold"></span>
</p>
</div>
)}
{/* 预览 */}
{!finishDirectly && 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 ||
(!finishDirectly &&
(!nextTaskName.trim() || allAssignees.length === 0))
}
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
finishDirectly
? "bg-amber-600 hover:bg-amber-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{finishDirectly ? "🏁 确认直接完结" : "确认转交"}
</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} 对应的产品`
: extractErrorMessage(err, "查询失败,请检查后端服务");
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(extractErrorMessage(err, "接收失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason, images);
toast("任务已驳回,已自动创建返工任务", "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(extractErrorMessage(err, "驳回失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id,
assignees,
nextTaskName,
note || undefined,
finishDirectly
);
toast(result.message, "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(extractErrorMessage(err, "转交失败"), "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);
}