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

View File

@ -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 || "";

View File

@ -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<TaskTransferResponse> {
const payload: TaskTransferPayload = finishDirectly
? { next_tasks: [], finish_directly: true, note }
: { next_assignees: nextAssignees, next_task_name: nextTaskName, note };
const { data } = await api.post<TaskTransferResponse>(
`/tasks/${taskId}/transfer`,
{
next_assignees: nextAssignees,
next_task_name: nextTaskName,
note,
} satisfies TaskTransferPayload
payload
);
return data;
}

View File

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