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 签名对齐
This commit is contained in:
2026-09-17 14:07:05 +08:00
parent 1e6c006360
commit e707f3673d
4 changed files with 163 additions and 38 deletions

View File

@ -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<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 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 (
<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">
<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 text-green-500">
完成任务并批量创建下一道工序任务,支持多路裂变分支
<p
className={`mt-0.5 text-xs ${
finishDirectly ? "text-amber-500" : "text-green-500"
}`}
>
{finishDirectly
? "结束本任务且不创建下游任务,产品状态保持原样"
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
</p>
</div>
@ -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
/>
</div>
@ -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"
/>
<button
type="button"
onClick={handleAddAssignee}
disabled={!assigneeInput.trim()}
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" />
@ -390,11 +449,16 @@ export const TransferModal = memo(function TransferModal({
</div>
{/* 虚拟入库 */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<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" />
@ -403,6 +467,28 @@ export const TransferModal = memo(function TransferModal({
</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">
@ -417,8 +503,21 @@ export const TransferModal = memo(function TransferModal({
/>
</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>
)}
{/* 预览 */}
{allAssignees.length > 0 && nextTaskName.trim() && (
{!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">
@ -460,13 +559,17 @@ export const TransferModal = memo(function TransferModal({
type="submit"
disabled={
submitting ||
!nextTaskName.trim() ||
allAssignees.length === 0
(!finishDirectly &&
(!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"
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>
@ -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);