From 3c4c16a1cca7deaff9d3fc34cb340e1e6199476d Mon Sep 17 00:00:00 2001 From: duxingchen Date: Sun, 9 Aug 2026 17:55:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=9BBug=E2=80=94=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=BA=92=E6=96=A5+=E4=B8=AD=E6=96=87=E5=A7=93=E5=90=8D+?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=BC=B9=E7=AA=97+=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E5=9B=9E=E6=BA=AF(=E5=90=ABRecordsModal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/task_service.py | 50 +++ .../src/components/TaskTree/TaskFlowView.tsx | 336 +++++++++--------- 2 files changed, 223 insertions(+), 163 deletions(-) diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 6c1f2be..3ec0c92 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -34,6 +34,46 @@ VIRTUAL_WAREHOUSE = "virtual_warehouse" ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"} +async def _recalc_product_location(db: AsyncSession, product_id: uuid.UUID) -> None: + """ + 任务完工/结束时触发:沿任务树向上回溯, + 将产品 current_location 更新为最近一个 WIP 任务的负责人。 + 若无进行中任务,位置置空。 + """ + from sqlalchemy import select as sa_select + product_result = await db.execute(sa_select(Product).where(Product.id == product_id)) + product = product_result.scalar_one_or_none() + if not product: + return + + # 查找所有 WIP 状态的任务 + task_result = await db.execute( + sa_select(Task).where( + Task.product_id == product_id, + Task.status == TASK_STATUS_WIP, + ).order_by(Task.created_at.desc()) + ) + wip_tasks = task_result.scalars().all() + + if wip_tasks: + # 有进行中的任务 → 位置更新为最新WIP任务的负责人 + latest_wip = wip_tasks[0] + new_location = latest_wip.assignee_id or product.current_location_id + else: + # 无进行中任务 → 查找最新的COMPLETED任务负责人(保留最后完工者) + done_result = await db.execute( + sa_select(Task).where( + Task.product_id == product_id, + Task.status == TASK_STATUS_COMPLETED, + ).order_by(Task.completed_at.desc()).limit(1) + ) + last_done = done_result.scalar_one_or_none() + new_location = last_done.assignee_id if last_done else None + + if product.current_location_id != new_location: + product.current_location_id = new_location + + def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None: """权限校验:本人 或 管理员/主管 可操作""" if operator_role and operator_role in ADMIN_ROLES: @@ -324,6 +364,9 @@ async def end_task( await _create_task_log(db, task_id, action_type="end", operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)") + # 🔧 位置回溯:分支结束后重新计算产品当前位置 + await _recalc_product_location(db, task.product_id) + await db.commit() await db.refresh(task) return _to_response(task) @@ -763,6 +806,10 @@ async def transfer_task( product.current_location_id = real_branches[0][1] product.overall_status = real_branches[0][0] + # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到上级WIP任务 + if not real_branches and not has_warehouse: + await _recalc_product_location(db, task.product_id) + await db.commit() # --- 构建响应 --- @@ -861,6 +908,9 @@ async def complete_task( remark=f"由任务「{task.task_name}」完成后转交创建", ) + # 🔧 位置回溯:老接口也触发 + await _recalc_product_location(db, task.product_id) + await db.commit() # --- 5. 构建响应 --- diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx index f39951e..b255a67 100644 --- a/frontend/src/components/TaskTree/TaskFlowView.tsx +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -1,28 +1,18 @@ /** - * 任务流转卡片堆叠视图 — 水平分支 + 卡片层叠布局 - * - * 与移动端 TaskSwipeCards.vue 核心逻辑完全对齐: - * - 任务树按 TRANSFER/SPAWN 分类为主线/分支 - * - 状态标签、时间格式、卡片元素一致 - * - 三种权限模式(本人/管理员/只读) + * 任务流转卡片堆叠视图 — 与移动端 TaskSwipeCards.vue 核心逻辑完全对齐 */ -import { memo, useMemo } from "react"; +import { memo, useMemo, useState } from "react"; import { - GitBranch, - ArrowDown, - AlertTriangle, - Clock, - CheckCircle, - Flag, - FileText, + GitBranch, ArrowDown, AlertTriangle, Clock, + CheckCircle, Flag, FileText, X, Image, } from "lucide-react"; -import type { TaskResponse } from "../../types/api"; +import type { TaskResponse, TaskRecordResponse } from "../../types/api"; import { TASK_STATUS } from "../../types/api"; import { getStatusConfig } from "../../constants/task"; import type { ModalTarget } from "./TaskTreeViewer"; // ============================================================ -// 工具函数 — 与移动端 fmtTime/statusLabel 完全一致 +// 工具函数 // ============================================================ function fmtTime(d: string | null) { @@ -32,23 +22,34 @@ function fmtTime(d: string | null) { return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; } -/** 判断任务是否为主线(与移动端 isMain 逻辑一致) */ function isMainTask(t: TaskResponse): boolean { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY" || (!t.task_type && !!t.parent_task_id); } // ============================================================ -// 类型 +// 记录图片渲染 // ============================================================ -interface LevelGroup { - depth: number; - tasks: TaskResponse[]; +function parseImages(imagesStr: string | null | undefined): string[] { + if (!imagesStr) return []; + try { return JSON.parse(imagesStr); } catch { return []; } +} + +function imageUrl(url: string) { + if (!url) return ""; + if (url.startsWith("http")) return url; + return import.meta.env.VITE_API_BASE_URL + (url.startsWith("/") ? url : "/" + url); } // ============================================================ -// 任务树解析 — 按 depth 拍平(与移动端 lanes 逻辑结构等价) +// 类型 +// ============================================================ + +interface LevelGroup { depth: number; tasks: TaskResponse[]; } + +// ============================================================ +// 任务树解析 // ============================================================ function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] { @@ -76,21 +77,19 @@ function groupByDepth(levels: LevelGroup[]): Map { } // ============================================================ -// 单张任务卡片 — 与移动端 ss-card 完全对齐 +// 单张任务卡片 // ============================================================ const FlowCard = memo(function FlowCard({ - task, - isActive, - onAction, - currentUser, - assigneeName, + task, isActive, onAction, currentUser, assigneeName, + onViewRecords, }: { task: TaskResponse; isActive: boolean; onAction: (target: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; assigneeName?: string; + onViewRecords?: (task: TaskResponse) => void; }) { const cfg = getStatusConfig(task.status); const dwell = calcDwell(task.received_at, task.completed_at, task.status); @@ -99,74 +98,49 @@ const FlowCard = memo(function FlowCard({ const isCanceled = task.status?.toUpperCase() === "CANCELED"; const main = isMainTask(task); - // 权限:本人或管理员 const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username); const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR"; - const canOperate = isOwner || isManager; return (
- {/* 主线/分支 角标 — 与移动端 tc-ribbon 一致 */} -
+ {/* 主线/分支 角标 */} +
{main ? "主分支" : "分支"}
- {/* 返工/入库标记 — 与移动端 tag-rework-sm 一致 */} + {/* 返工/入库 */}
- {task.is_rework && ( - - 返工 - - )} - {task.status === "ARCHIVED" && ( - - 📦 入库 - - )} + {task.is_rework && 返工} + {task.status === "ARCHIVED" && 📦 入库}
- {/* 头部:类型标签 + 状态 — 与移动端 tc-head 一致 */} + {/* 头部 */}
- - {main ? "主分支" : "分支"} - - {task.child_tasks.length > 1 && ( - - - 裂变×{task.child_tasks.length} - - )} + {main ? "主分支" : "分支"} + {task.child_tasks.length > 1 && 裂变×{task.child_tasks.length}}
- - {cfg.label} - + {cfg.label}
- {/* 任务名 — 与移动端 tc-name 一致 */} + {/* 任务名 */}

{task.task_name}

- {/* 负责人 — 与移动端 tc-meta 一致 */} + {/* 负责人 */}
👤 负责人 {assigneeName || task.assignee_id || "未分配"}
- {/* 备注 — 与移动端 tc-remark-box 一致 */} + {/* 备注 */} {task.remark && (

📌 备注

@@ -174,17 +148,19 @@ const FlowCard = memo(function FlowCard({
)} - {/* 提交记录 — 与移动端 tc-records-link 一致 */} + {/* 提交记录 — 可点击 */} {task.records && task.records.length > 0 && ( -
+
{ e.stopPropagation(); onViewRecords?.(task); }} + className="mt-2 flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 px-2.5 py-2 transition-colors hover:from-blue-100 hover:to-indigo-100" + > - - 共 {task.records.length} 条提交记录 - + 共 {task.records.length} 条提交记录 + 查看 ›
)} - {/* 停留耗时 — 与移动端逻辑一致 */} + {/* 耗时 */} {dwell && (

@@ -193,80 +169,54 @@ const FlowCard = memo(function FlowCard({ )} {/* 驳回原因 */} - {task.reject_reason && ( -

驳回原因: {task.reject_reason}

- )} + {task.reject_reason &&

驳回原因: {task.reject_reason}

} - {/* 状态标记 — 与移动端 tc-stats 一致 */} + {/* 状态标记 */}
- {task.received_at && ( - - 已接收 - - )} - {task.completed_at && ( - - 已完工 - - )} + {task.received_at && 已接收} + {task.completed_at && 已完工}
- {/* 时间 — 与移动端 fmtTime 格式一致 */} + {/* 时间 */}

创建: {fmtTime(task.created_at)}

{task.received_at &&

接收: {fmtTime(task.received_at)}

} {task.completed_at &&

完工: {fmtTime(task.completed_at)}

}
- {/* 操作按钮 — 三种权限模式 */} - {isActive && canOperate && ( + {/* 🔧 BugFix #1: 权限严格互斥 */} + {/* 本人:正常操作按钮 */} + {isActive && isOwner && (
{task.status?.toUpperCase() === TASK_STATUS.PENDING && ( <> - - - + + + )} {task.status?.toUpperCase() === TASK_STATUS.WIP && ( <> - - + + )}
)} + + {/* 管理员看别人:仅干预按钮 */} {isActive && !isOwner && isManager && (

⚠ 运维干预模式

- - + +
)} - {isActive && !canOperate && ( + + {/* 普通人看别人:什么按钮都没有 */} + {isActive && !isOwner && !isManager && (

非当前任务责任人,无法操作

@@ -276,14 +226,78 @@ const FlowCard = memo(function FlowCard({ }); // ============================================================ -// 停留耗时 — 与移动端逻辑一致 +// 记录查看弹窗 — 与移动端 records 页面样式对齐 // ============================================================ -function calcDwell( - receivedAt: string | null, - completedAt: string | null, - status: string, -): { text: string; highlight: boolean } | null { +export const RecordsModal = memo(function RecordsModal({ + open, task, onClose, +}: { + open: boolean; task: TaskResponse | null; onClose: () => void; +}) { + if (!open || !task) return null; + const records = task.records || []; + + return ( +
+
+
+
+
+

提交记录

+

{task.task_name}

+
+ +
+ +
+ {records.length === 0 ? ( +
+ 📭 +

暂无历史记录

+
+ ) : ( +
+ {[...records].reverse().map((rec, i) => ( +
+ {/* 时间轴竖线 */} +
+
+ {i < records.length - 1 &&
} +
+ {/* 内容卡片 */} +
+

{fmtTime(rec.created_at)}

+ {rec.note &&

{rec.note}

} + {rec.remark &&

{rec.remark}

} + {/* 图片 */} + {(() => { + const imgs = parseImages((rec as any).images); + if (!imgs.length) return null; + return ( +
+ {imgs.map((img, j) => ( + window.open(imageUrl(img), "_blank")} /> + ))} +
+ ); + })()} +
+
+ ))} +
+ )} +
+
+
+ ); +}); + +// ============================================================ +// 停留耗时 +// ============================================================ + +function calcDwell(receivedAt: string | null, completedAt: string | null, status: string): { text: string; highlight: boolean } | null { if (!receivedAt) return null; const start = new Date(receivedAt).getTime(); const end = completedAt ? new Date(completedAt).getTime() : Date.now(); @@ -312,6 +326,8 @@ interface TaskFlowViewProps { } export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) { + const [recordsTask, setRecordsTask] = useState(null); + const depthMap = useMemo(() => { if (!tasks || tasks.length === 0) return new Map(); return groupByDepth(flattenLevels(tasks)); @@ -321,44 +337,38 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren if (depths.length === 0) return null; return ( -
- {depths.map((depth) => { - const levelTasks = depthMap.get(depth) || []; - return ( -
-
- - {depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`} - -
-
-
- {levelTasks.map((task) => { - const isActive = - task.status?.toUpperCase() === TASK_STATUS.PENDING || - task.status?.toUpperCase() === TASK_STATUS.WIP; - return ( -
- -
- ); - })} -
- {depth < depths.length - 1 && ( -
- + <> +
+ {depths.map((depth) => { + const levelTasks = depthMap.get(depth) || []; + return ( +
+
+ {depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`} +
- )} -
- ); - })} -
+
+ {levelTasks.map((task) => { + const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || task.status?.toUpperCase() === TASK_STATUS.WIP; + return ( +
+ +
+ ); + })} +
+ {depth < depths.length - 1 && ( +
+ )} +
+ ); + })} +
+ setRecordsTask(null)} /> + ); });