diff --git a/frontend/src/components/TaskTree/TaskTreeViewer.tsx b/frontend/src/components/TaskTree/TaskTreeViewer.tsx index e06273d..c040f15 100644 --- a/frontend/src/components/TaskTree/TaskTreeViewer.tsx +++ b/frontend/src/components/TaskTree/TaskTreeViewer.tsx @@ -237,14 +237,35 @@ export const TransferModal = memo(function TransferModal({ task: TaskResponse | null; submitting: boolean; onClose: () => void; - onSubmit: (nextTaskName: string, assignees: string[], note: string) => void; + onSubmit: ( + nextTaskName: string, + assignees: string[], + note: string, + finishDirectly: boolean + ) => void; }) { const [nextTaskName, setNextTaskName] = useState(""); const [assigneeInput, setAssigneeInput] = useState(""); const [assignees, setAssignees] = useState([]); 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 Hooks,task 从 null 变非 null 时 Hook 数量错位会直接崩。 + useEffect(() => { + if (!open) return; + setNextTaskName(""); + setAssigneeInput(""); + setAssignees([]); + setUseWarehouse(false); + setFinishDirectly(false); + setNote(""); + }, [open]); + if (!task) return null; function handleAddAssignee() { @@ -271,44 +292,80 @@ export const TransferModal = memo(function TransferModal({ function handleSubmit(e: React.FormEvent) { e.preventDefault(); - const name = nextTaskName.trim(); - if (!name) return; - const finalAssignees = [...assignees]; - if (useWarehouse) { - finalAssignees.push("virtual_warehouse"); + // 🏁 直接完结:不需要工序名与接收人,只闭环当前任务 + 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); } - if (finalAssignees.length === 0) return; + // ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。 + // 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。 + // 统一改由下方的「打开时重置」负责。 + } - onSubmit(name, finalAssignees, note.trim()); - // 重置表单 + function resetForm() { setNextTaskName(""); setAssignees([]); setAssigneeInput(""); setUseWarehouse(false); + setFinishDirectly(false); setNote(""); } function handleClose() { - setNextTaskName(""); - setAssignees([]); - setAssigneeInput(""); - setUseWarehouse(false); - setNote(""); + resetForm(); onClose(); } + // 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回) + const downstreamDisabled = finishDirectly; + // 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效 + function handleToggleFinishDirectly(checked: boolean) { + setFinishDirectly(checked); + if (checked) setUseWarehouse(false); + } + const allAssignees = [ ...assignees, ...(useWarehouse ? ["virtual_warehouse"] : []), ]; return ( - -
+ +

{task.task_name}

-

- 完成任务并批量创建下一道工序任务,支持多路裂变分支 +

+ {finishDirectly + ? "结束本任务且不创建下游任务,产品状态保持原样" + : "完成任务并批量创建下一道工序任务,支持多路裂变分支"}

@@ -324,7 +381,8 @@ export const TransferModal = memo(function TransferModal({ 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" + 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 />
@@ -375,12 +433,13 @@ export const TransferModal = memo(function TransferModal({ 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" + 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" /> @@ -573,7 +676,8 @@ export default function TaskTreeViewer() { async function handleTransfer( nextTaskName: string, assignees: string[], - note: string + note: string, + finishDirectly: boolean ) { if (!modalTarget) return; setSubmitting(true); @@ -582,7 +686,8 @@ export default function TaskTreeViewer() { modalTarget.task.id, assignees, nextTaskName, - note || undefined + note || undefined, + finishDirectly ); toast(result.message, "success"); setModalTarget(null); diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index 23fe09f..0a35d4c 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -528,12 +528,13 @@ export default function AdminTasksPage() { 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, + modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly, ); toast(result.message, "success"); const sn = modalTarget.task.product_sn || ""; diff --git a/frontend/src/services/taskApi.ts b/frontend/src/services/taskApi.ts index 6ce444e..b5a290d 100644 --- a/frontend/src/services/taskApi.ts +++ b/frontend/src/services/taskApi.ts @@ -114,20 +114,25 @@ export async function rejectTask( /** * 完工裂变转交 — → COMPLETED,批量创建下家任务。 * 对应后端 POST /api/v1/tasks/{taskId}/transfer + * + * finishDirectly=true 时走「直接完结」通道:next_tasks 留空 + finish_directly=true, + * 后端据此跳过分支解析,只闭环任务【不产生下游任务】、且不改产品宏观状态 + * (不会把产品误标成「待仓库收货」)。用于售后返厂直接发走 / 半成品被提走等无需入库的收官场景。 */ export async function transferTask( taskId: string, nextAssignees: string[], nextTaskName: string, - note?: string + note?: string, + finishDirectly = false ): Promise { + const payload: TaskTransferPayload = finishDirectly + ? { next_tasks: [], finish_directly: true, note } + : { next_assignees: nextAssignees, next_task_name: nextTaskName, note }; + const { data } = await api.post( `/tasks/${taskId}/transfer`, - { - next_assignees: nextAssignees, - next_task_name: nextTaskName, - note, - } satisfies TaskTransferPayload + payload ); return data; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 34b0529..f6a440f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -86,9 +86,23 @@ export interface TaskRejectPayload { images: string[]; } +/** 裂变分支 — assignees 允许为空数组,但【仅当】finish_directly=true 时合法; + * 否则后端 TaskTransferRequest 校验会直接 422 拒绝(防「转交」静默退化成「直接完结」的丢件隐患)。 */ +export interface TaskTransferBranch { + task_name: string; + assignees: string[]; +} + export interface TaskTransferPayload { - next_assignees: string[]; - next_task_name: string; + /** [旧版] 下一道工序接收人列表 — 与 next_tasks 二选一 */ + next_assignees?: string[]; + /** [旧版] 下一道工序名称 — 与 next_tasks 二选一 */ + next_task_name?: string; + /** [新版] 多分支任务列表 */ + next_tasks?: TaskTransferBranch[]; + /** 🏁 直接完结:闭环当前任务但【不产生任何下游任务】,产品 overall_status 保持原样 + * (不入库 → 不会变成「待仓库收货」)。置 true 时后端忽略 next_tasks / next_assignees。 */ + finish_directly?: boolean; note?: string; }