fix: 四Bug—权限互斥+中文姓名+记录弹窗+后端位置回溯(含RecordsModal)
This commit is contained in:
@ -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. 构建响应 ---
|
||||
|
||||
@ -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<number, TaskResponse[]> {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单张任务卡片 — 与移动端 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 (
|
||||
<div
|
||||
className={`relative shrink-0 w-56 rounded-xl border-2 bg-white p-3.5 shadow-md transition-all ${
|
||||
isActive
|
||||
? `border-blue-400 ${cfg.ring} shadow-lg shadow-blue-100 scale-105 z-10`
|
||||
: isCompleted || isArchived
|
||||
? "border-gray-200 opacity-70"
|
||||
: isCanceled
|
||||
? "border-gray-200 opacity-50"
|
||||
: isCompleted || isArchived ? "border-gray-200 opacity-70"
|
||||
: isCanceled ? "border-gray-200 opacity-50"
|
||||
: "border-gray-200 hover:shadow-lg"
|
||||
} ${task.is_rework ? "border-l-red-500 border-l-4" : ""}`}
|
||||
>
|
||||
{/* 主线/分支 角标 — 与移动端 tc-ribbon 一致 */}
|
||||
<div
|
||||
className={`absolute top-3 right-3 rounded px-2 py-0.5 text-[9px] font-bold text-white z-2 ${
|
||||
main ? "bg-blue-600" : "bg-purple-500"
|
||||
}`}
|
||||
>
|
||||
{/* 主线/分支 角标 */}
|
||||
<div className={`absolute top-3 right-3 rounded px-2 py-0.5 text-[9px] font-bold text-white z-2 ${main ? "bg-blue-600" : "bg-purple-500"}`}>
|
||||
{main ? "主分支" : "分支"}
|
||||
</div>
|
||||
|
||||
{/* 返工/入库标记 — 与移动端 tag-rework-sm 一致 */}
|
||||
{/* 返工/入库 */}
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
{task.is_rework && (
|
||||
<span className="inline-flex items-center rounded bg-red-600 px-1.5 py-0.5 text-[9px] font-bold text-white animate-pulse">
|
||||
<AlertTriangle className="mr-0.5 h-2.5 w-2.5" />返工
|
||||
</span>
|
||||
)}
|
||||
{task.status === "ARCHIVED" && (
|
||||
<span className="inline-flex items-center rounded border border-dashed border-purple-300 bg-purple-50 px-1.5 py-0.5 text-[9px] font-bold text-purple-600">
|
||||
📦 入库
|
||||
</span>
|
||||
)}
|
||||
{task.is_rework && <span className="inline-flex items-center rounded bg-red-600 px-1.5 py-0.5 text-[9px] font-bold text-white animate-pulse"><AlertTriangle className="mr-0.5 h-2.5 w-2.5" />返工</span>}
|
||||
{task.status === "ARCHIVED" && <span className="inline-flex items-center rounded border border-dashed border-purple-300 bg-purple-50 px-1.5 py-0.5 text-[9px] font-bold text-purple-600">📦 入库</span>}
|
||||
</div>
|
||||
|
||||
{/* 头部:类型标签 + 状态 — 与移动端 tc-head 一致 */}
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`rounded px-2 py-0.5 text-[9px] font-bold text-white ${main ? "bg-blue-600" : "bg-purple-100 text-purple-700"}`}>
|
||||
{main ? "主分支" : "分支"}
|
||||
</span>
|
||||
{task.child_tasks.length > 1 && (
|
||||
<span className="inline-flex items-center rounded bg-purple-100 px-1.5 py-0.5 text-[9px] font-medium text-purple-700">
|
||||
<GitBranch className="mr-0.5 h-2.5 w-2.5" />
|
||||
裂变×{task.child_tasks.length}
|
||||
</span>
|
||||
)}
|
||||
<span className={`rounded px-2 py-0.5 text-[9px] font-bold text-white ${main ? "bg-blue-600" : "bg-purple-100 text-purple-700"}`}>{main ? "主分支" : "分支"}</span>
|
||||
{task.child_tasks.length > 1 && <span className="inline-flex items-center rounded bg-purple-100 px-1.5 py-0.5 text-[9px] font-medium text-purple-700"><GitBranch className="mr-0.5 h-2.5 w-2.5" />裂变×{task.child_tasks.length}</span>}
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${cfg.bg} ${cfg.text}`}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 任务名 — 与移动端 tc-name 一致 */}
|
||||
{/* 任务名 */}
|
||||
<p className="mt-2 text-base font-extrabold text-gray-800 leading-tight">{task.task_name}</p>
|
||||
|
||||
{/* 负责人 — 与移动端 tc-meta 一致 */}
|
||||
{/* 负责人 */}
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px]">
|
||||
<span className="text-gray-400">👤 负责人</span>
|
||||
<span className="font-semibold text-gray-700">{assigneeName || task.assignee_id || "未分配"}</span>
|
||||
</div>
|
||||
|
||||
{/* 备注 — 与移动端 tc-remark-box 一致 */}
|
||||
{/* 备注 */}
|
||||
{task.remark && (
|
||||
<div className="mt-2 rounded-lg border border-yellow-200 bg-yellow-50 px-2.5 py-2">
|
||||
<p className="text-[10px] font-bold text-yellow-700">📌 备注</p>
|
||||
@ -174,17 +148,19 @@ const FlowCard = memo(function FlowCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交记录 — 与移动端 tc-records-link 一致 */}
|
||||
{/* 提交记录 — 可点击 */}
|
||||
{task.records && task.records.length > 0 && (
|
||||
<div className="mt-2 flex 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">
|
||||
<div
|
||||
onClick={(e) => { 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"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="flex-1 text-[11px] font-bold text-blue-600">
|
||||
共 {task.records.length} 条提交记录
|
||||
</span>
|
||||
<span className="flex-1 text-[11px] font-bold text-blue-600">共 {task.records.length} 条提交记录</span>
|
||||
<span className="text-[10px] text-blue-400">查看 ›</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 停留耗时 — 与移动端逻辑一致 */}
|
||||
{/* 耗时 */}
|
||||
{dwell && (
|
||||
<p className={`mt-1.5 flex items-center gap-1 text-[10px] ${dwell.highlight ? "text-red-500 font-semibold" : "text-orange-500"}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
@ -193,80 +169,54 @@ const FlowCard = memo(function FlowCard({
|
||||
)}
|
||||
|
||||
{/* 驳回原因 */}
|
||||
{task.reject_reason && (
|
||||
<p className="mt-1 text-[10px] text-red-500 line-clamp-2">驳回原因: {task.reject_reason}</p>
|
||||
)}
|
||||
{task.reject_reason && <p className="mt-1 text-[10px] text-red-500 line-clamp-2">驳回原因: {task.reject_reason}</p>}
|
||||
|
||||
{/* 状态标记 — 与移动端 tc-stats 一致 */}
|
||||
{/* 状态标记 */}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{task.received_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-1.5 py-0.5 text-[9px] font-semibold text-gray-600">
|
||||
<CheckCircle className="h-2.5 w-2.5" />已接收
|
||||
</span>
|
||||
)}
|
||||
{task.completed_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-1.5 py-0.5 text-[9px] font-semibold text-green-600">
|
||||
<Flag className="h-2.5 w-2.5" />已完工
|
||||
</span>
|
||||
)}
|
||||
{task.received_at && <span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-1.5 py-0.5 text-[9px] font-semibold text-gray-600"><CheckCircle className="h-2.5 w-2.5" />已接收</span>}
|
||||
{task.completed_at && <span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-1.5 py-0.5 text-[9px] font-semibold text-green-600"><Flag className="h-2.5 w-2.5" />已完工</span>}
|
||||
</div>
|
||||
|
||||
{/* 时间 — 与移动端 fmtTime 格式一致 */}
|
||||
{/* 时间 */}
|
||||
<div className="mt-2 border-t border-gray-100 pt-2">
|
||||
<p className="text-[9px] text-gray-400">创建: {fmtTime(task.created_at)}</p>
|
||||
{task.received_at && <p className="text-[9px] text-gray-400">接收: {fmtTime(task.received_at)}</p>}
|
||||
{task.completed_at && <p className="text-[9px] text-gray-400">完工: {fmtTime(task.completed_at)}</p>}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 — 三种权限模式 */}
|
||||
{isActive && canOperate && (
|
||||
{/* 🔧 BugFix #1: 权限严格互斥 */}
|
||||
{/* 本人:正常操作按钮 */}
|
||||
{isActive && isOwner && (
|
||||
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
|
||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
|
||||
<>
|
||||
<button onClick={() => onAction({ task, action: "receive" })}
|
||||
className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100">
|
||||
接收
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "reject" })}
|
||||
className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">
|
||||
驳回
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })}
|
||||
className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">
|
||||
转交
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100">接收</button>
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">转交</button>
|
||||
</>
|
||||
)}
|
||||
{task.status?.toUpperCase() === TASK_STATUS.WIP && (
|
||||
<>
|
||||
<button onClick={() => onAction({ task, action: "reject" })}
|
||||
className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">
|
||||
驳回
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })}
|
||||
className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">
|
||||
转交
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">转交</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 管理员看别人:仅干预按钮 */}
|
||||
{isActive && !isOwner && isManager && (
|
||||
<div className="mt-2 border-t border-orange-100 pt-2">
|
||||
<p className="mb-1 text-[9px] text-orange-500">⚠ 运维干预模式</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => onAction({ task, action: "reject" })}
|
||||
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
|
||||
强制驳回
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })}
|
||||
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
|
||||
强制转交
|
||||
</button>
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">强制驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">强制转交</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isActive && !canOperate && (
|
||||
|
||||
{/* 普通人看别人:什么按钮都没有 */}
|
||||
{isActive && !isOwner && !isManager && (
|
||||
<div className="mt-2 border-t border-gray-100 pt-2">
|
||||
<p className="text-center text-[9px] text-gray-400">非当前任务责任人,无法操作</p>
|
||||
</div>
|
||||
@ -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 (
|
||||
<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 max-w-md max-h-[80vh] overflow-y-auto rounded-xl bg-white shadow-2xl">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-5 py-4 rounded-t-xl">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-800">提交记录</h3>
|
||||
<p className="text-xs text-gray-400">{task.task_name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-5 w-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3">
|
||||
{records.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-12 text-gray-400">
|
||||
<span className="text-4xl mb-2">📭</span>
|
||||
<p className="text-sm">暂无历史记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[...records].reverse().map((rec, i) => (
|
||||
<div key={rec.id} className="relative pl-6">
|
||||
{/* 时间轴竖线 */}
|
||||
<div className="absolute left-2 top-0 bottom-0 flex flex-col items-center">
|
||||
<div className={`h-3 w-3 rounded-full ${i === 0 ? "bg-blue-500 shadow-[0_0_0_4px_rgba(37,99,235,0.15)]" : "bg-gray-300"}`} />
|
||||
{i < records.length - 1 && <div className="flex-1 w-0.5 bg-gray-200 min-h-[12px]" />}
|
||||
</div>
|
||||
{/* 内容卡片 */}
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2.5">
|
||||
<p className="text-[11px] text-gray-400">{fmtTime(rec.created_at)}</p>
|
||||
{rec.note && <p className="mt-1 text-sm text-gray-700 leading-relaxed">{rec.note}</p>}
|
||||
{rec.remark && <p className="mt-1 text-sm text-gray-700 leading-relaxed">{rec.remark}</p>}
|
||||
{/* 图片 */}
|
||||
{(() => {
|
||||
const imgs = parseImages((rec as any).images);
|
||||
if (!imgs.length) return null;
|
||||
return (
|
||||
<div className="mt-2 flex gap-1.5 flex-wrap">
|
||||
{imgs.map((img, j) => (
|
||||
<img key={j} src={imageUrl(img)} alt="" className="h-16 w-16 rounded-lg border border-gray-200 object-cover cursor-pointer hover:opacity-80"
|
||||
onClick={() => window.open(imageUrl(img), "_blank")} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 停留耗时
|
||||
// ============================================================
|
||||
|
||||
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<TaskResponse | null>(null);
|
||||
|
||||
const depthMap = useMemo(() => {
|
||||
if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>();
|
||||
return groupByDepth(flattenLevels(tasks));
|
||||
@ -321,44 +337,38 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
if (depths.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{depths.map((depth) => {
|
||||
const levelTasks = depthMap.get(depth) || [];
|
||||
return (
|
||||
<div key={depth}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
{depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">{depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`}</span>
|
||||
<div className="h-px flex-1 bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 pl-2" style={{ scrollSnapType: "x mandatory" }}>
|
||||
{levelTasks.map((task) => {
|
||||
const isActive =
|
||||
task.status?.toUpperCase() === TASK_STATUS.PENDING ||
|
||||
task.status?.toUpperCase() === TASK_STATUS.WIP;
|
||||
const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || task.status?.toUpperCase() === TASK_STATUS.WIP;
|
||||
return (
|
||||
<div key={task.id} style={{ scrollSnapAlign: "start" }}>
|
||||
<FlowCard
|
||||
task={task}
|
||||
isActive={isActive}
|
||||
onAction={onAction}
|
||||
<FlowCard task={task} isActive={isActive} onAction={onAction}
|
||||
currentUser={currentUser}
|
||||
assigneeName={assigneeNames?.[task.assignee_id || ""]}
|
||||
/>
|
||||
onViewRecords={setRecordsTask} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{depth < depths.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<ArrowDown className="h-4 w-4 text-gray-300" />
|
||||
</div>
|
||||
<div className="flex justify-center py-1"><ArrowDown className="h-4 w-4 text-gray-300" /></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RecordsModal open={!!recordsTask} task={recordsTask} onClose={() => setRecordsTask(null)} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user