各处 catch 里直接 `toast(err?.response?.data?.detail ?? "xx失败")` 有坑: 请求体校验失败(422)时 detail 是数组,React 会把每个对象渲染成 [object Object]。 - utils/errorMessage.ts(新增): extractErrorMessage 统一解析三种形态 —— 字符串 / FastAPI 校验错误数组 / 对象;数组取 msg 并剥掉 Pydantic 的 "Value error, " 前缀、带上字段名、同字段去重后用「;」拼接;解析不出内容 则回落到调用方给的兜底文案。行为与移动端 extractErrorDetail 对齐 - 19 处调用点全部替换:AdminTasksPage(4) / AdminProductsPage(5) / TaskTreeViewer(4) / AdminPrintConfigPage(2) / ScanPage / CreateProductDialog / ImageUploader / authApi - TaskRejectPayload.images 与 taskApi.rejectTask 的注释同步为「选填,支持空数组」 - 新增 components/ui/ImageUploader.tsx(驳回弹窗选图上传组件), RejectModal 同步放开无图拦截:去掉 images 非空校验、提交按钮不再因 无图禁用、标签由必填星号改为「(选填,最多 9 张)」,并移除因此失效的 error 状态 - AdminTasksPage.tsx: 支持大屏下钻的 ?search / ?status / ?stage 预设参数, 新增「售后流转中」状态 Tab(按 lifecycle_phase 判定)与全局重置; ?stage 只在跳入时生效一次,用户手动改条件即解除锁定 - 杂项清理: TaskFlowView 移除透传的 assigneeNames、TaskListCard/MyTasksPage 移除未使用导入、QrScanner 关闭 verbose 日志
355 lines
20 KiB
TypeScript
355 lines
20 KiB
TypeScript
/**
|
||
* 流转树双模式可视化 — 焦点模式 + 全景模式
|
||
*/
|
||
import { memo, useMemo, useState, useEffect } from "react";
|
||
import { Image } from "antd";
|
||
import { Package, User, X } from "lucide-react";
|
||
import type { TaskResponse } from "../../types/api";
|
||
import { TASK_STATUS } from "../../types/api";
|
||
import { getStatusConfig } from "../../constants/task";
|
||
import type { ModalTarget } from "./TaskTreeViewer";
|
||
|
||
// ============================================================
|
||
// 工具
|
||
// ============================================================
|
||
function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d); const pad = (n: number) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
|
||
function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY" || t.task_type === "WAREHOUSE"; }
|
||
function active(s: string) { return s === "WIP" || s === "PENDING"; }
|
||
/** 微型右箭头 SVG */
|
||
function ArrowRight({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
|
||
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
|
||
}
|
||
/** 微型左箭头 SVG */
|
||
function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
|
||
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
|
||
}
|
||
function parseImages(s: any): string[] {
|
||
if (!s) return [];
|
||
if (Array.isArray(s)) return s; // Pydantic 序列化后的数组
|
||
if (typeof s === "string") { try { return JSON.parse(s); } catch { return []; } }
|
||
return [];
|
||
}
|
||
function imageUrl(u: string) {
|
||
if (!u) return "";
|
||
if (u.startsWith("http")) return u; // 已是完整绝对地址(含跨域域名),直接用
|
||
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); // 去掉末尾斜杠
|
||
const path = u.startsWith("/") ? u : "/" + u;
|
||
// 后端图片固定返回 /api/v1/upload/files/... 这类 /api/ 开头的相对路径,
|
||
// 需拼上 base 才能访问到后端;同时避免重复前缀:
|
||
// - base 为纯域名(如 https://track_back.iris-rs.cn)→ 正常 base + path
|
||
// - base 以 /api 或 /api/v1 结尾 → 先剥掉该段,否则会拼出 /api/api/ 或 /api/v1/api/v1
|
||
if (path.startsWith("/api/")) {
|
||
const origin = base.replace(/\/api(\/v\d+)?$/, "");
|
||
return origin + path;
|
||
}
|
||
return base + path;
|
||
}
|
||
|
||
const ALL_TASKS = new Set<TaskResponse>();
|
||
function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); }
|
||
function findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; }
|
||
|
||
// ============================================================
|
||
// 尺寸映射表(sm=现状小卡片,lg=弹窗放大)
|
||
// ============================================================
|
||
const SIZE_MAP = {
|
||
sm: {
|
||
card: "w-44 p-2.5 shadow-sm",
|
||
badge: "-top-1.5 right-2 px-1.5 py-px text-[8px]",
|
||
title: "mt-1 text-xs",
|
||
metaRow: "mt-1",
|
||
status: "px-1.5 py-px text-[8px]",
|
||
assignee: "text-[9px]",
|
||
time: "mt-1 text-[8px]",
|
||
sub: "mt-1 text-[8px]",
|
||
btnRow: "mt-1.5 pt-1.5",
|
||
btn: "py-0.5 text-[8px]",
|
||
record: "mt-1 px-1.5 py-0.5 text-[8px]",
|
||
recordIcon: "h-2.5 w-2.5",
|
||
},
|
||
lg: {
|
||
card: "w-72 p-4 shadow-md",
|
||
badge: "-top-2 right-3 px-2 py-0.5 text-xs",
|
||
title: "mt-1.5 text-base",
|
||
metaRow: "mt-2",
|
||
status: "px-2 py-0.5 text-xs",
|
||
assignee: "text-sm",
|
||
time: "mt-2 text-xs",
|
||
sub: "mt-1.5 text-xs",
|
||
btnRow: "mt-2.5 pt-2.5",
|
||
btn: "py-1.5 text-sm",
|
||
record: "mt-2 px-2.5 py-1 text-xs",
|
||
recordIcon: "h-4 w-4",
|
||
},
|
||
} as const;
|
||
|
||
// ============================================================
|
||
// 极简卡片
|
||
// ============================================================
|
||
const SlimCard = memo(function SlimCard({
|
||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm",
|
||
}: {
|
||
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
|
||
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
|
||
onViewRecords?: (t: TaskResponse) => void;
|
||
rootMainId?: string;
|
||
size?: "sm" | "lg";
|
||
}) {
|
||
const cfg = getStatusConfig(task.status);
|
||
const sz = SIZE_MAP[size];
|
||
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
|
||
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
|
||
const main = isMain(task);
|
||
const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id;
|
||
// 📥 在库/入库任务:显示"谁转入在库"(创建该任务的人)
|
||
const isWarehouse = !!(
|
||
task.task_name?.includes("在库") ||
|
||
task.task_name?.includes("入库") ||
|
||
task.assignee_id === "virtual_warehouse"
|
||
);
|
||
|
||
return (
|
||
<div className={`relative shrink-0 rounded-lg border bg-white ${sz.card} ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
|
||
{/* 左上角:主线/分支标签 + 状态标签并排 */}
|
||
<div className={`${sz.metaRow} flex items-center gap-1`}>
|
||
<span className={`rounded px-1.5 py-px font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</span>
|
||
<span className={`rounded-full ${sz.status} font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||
</div>
|
||
<p className={`${sz.title} font-bold text-gray-800 truncate`}>{task.task_name}</p>
|
||
{/* 操作人(带用户图标)+ 在库转入人;仓储任务防御性渲染为系统节点 */}
|
||
<div className={`${sz.metaRow} flex items-center gap-1`}>
|
||
{(() => {
|
||
const isSystemTask = task.task_name?.includes("扫码") || task.task_type === "WAREHOUSE";
|
||
if (isSystemTask) {
|
||
// ★ 仓储系统任务:不请求用户数据,直接显示系统图标 + 固定名(assignee_id 为 null 不报错)
|
||
return (
|
||
<span className={`${sz.assignee} text-gray-400 truncate inline-flex items-center gap-0.5`}>
|
||
<Package className="h-2.5 w-2.5 shrink-0" />MOM 仓储系统
|
||
</span>
|
||
);
|
||
}
|
||
if (isWarehouse && task.assignee_id) {
|
||
return (
|
||
<span className={`${sz.sub} text-emerald-600`}>📥 转入在库: {assigneeName || task.assignee_id}</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className={`${sz.assignee} text-gray-400 truncate inline-flex items-center gap-0.5`}>
|
||
<User className="h-2.5 w-2.5 shrink-0" />{assigneeName || task.assignee_id || "—"}
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
{/* 单行时间(精确到分钟) */}
|
||
<p className={`${sz.time} text-gray-300`}>
|
||
⏰ {fmtTime(task.created_at)}
|
||
{task.completed_at ? ` → ${fmtTime(task.completed_at)}` : " → 至今"}
|
||
</p>
|
||
{/* 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 */}
|
||
{(() => {
|
||
const rj = task.reject_reason;
|
||
const rm = task.remark;
|
||
const txt = rj ? `❌ 驳回: ${rj}` : (rm && /驳回|返工/.test(rm) ? rm : null);
|
||
return txt ? <p className={`${sz.sub} text-red-500 whitespace-pre-wrap`}>{txt}</p> : null;
|
||
})()}
|
||
{legacy && <p className={`${sz.sub} text-orange-500`}>源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||
{/* 操作按钮 */}
|
||
{active && isOwner && (
|
||
<div className={`${sz.btnRow} flex gap-1 border-t border-gray-100`}>
|
||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className={`flex-1 rounded border border-blue-200 bg-blue-50 ${sz.btn} text-blue-600`}>接收</button>}
|
||
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-green-200 bg-green-50 ${sz.btn} text-green-600`}>转交</button>}
|
||
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-red-200 bg-red-50 ${sz.btn} text-red-500`}>驳回</button>
|
||
</div>
|
||
)}
|
||
{active && !isOwner && isManager && (
|
||
<div className={`${sz.btnRow} flex gap-1 border-t border-orange-100`}>
|
||
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}>强制驳回</button>
|
||
<button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}>强制转交</button>
|
||
</div>
|
||
)}
|
||
{/* 操作日志:全行蓝色横条,铺满卡片宽度(含 padding+border),整条可点击 */}
|
||
{task.records && task.records.length > 0 && (
|
||
<div
|
||
onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }}
|
||
className={`${size === "lg" ? "-mx-[17px] -mb-[17px]" : "-mx-[11px] -mb-[11px]"} mt-3 py-1.5 bg-blue-50 text-blue-600 text-center text-sm font-medium cursor-pointer transition-colors hover:bg-blue-100 border-t border-blue-100 rounded-b-lg`}
|
||
>
|
||
查看操作日志 ({task.records.length}条) <span className="font-bold text-blue-700">›</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
// ============================================================
|
||
// 主视图
|
||
// ============================================================
|
||
interface TaskFlowViewProps {
|
||
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
|
||
currentUser?: { username?: string; role?: string } | null;
|
||
assigneeNames?: Record<string, string>;
|
||
size?: "sm" | "lg";
|
||
}
|
||
|
||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames, size = "sm" }: TaskFlowViewProps) {
|
||
const [showFullMap, setShowFullMap] = useState(false);
|
||
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
|
||
|
||
// 🚀 记录弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
|
||
useEffect(() => {
|
||
if (!recordsTask) return;
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => { document.body.style.overflow = prev; };
|
||
}, [recordsTask]);
|
||
|
||
// 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
|
||
const { allMains, childMap } = useMemo(() => {
|
||
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
|
||
const all = Array.from(ALL_TASKS);
|
||
// 🚀 收集所有主线任务(全部进入中央垂直主轴)
|
||
const mains: TaskResponse[] = [];
|
||
for (const t of all) {
|
||
if (isMain(t)) mains.push(t);
|
||
}
|
||
mains.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||
// 🚀 构建全局 childMap(按 parent_task_id 索引直接子节点,保留真实树结构)
|
||
const childMap: Record<string, TaskResponse[]> = {};
|
||
for (const t of all) {
|
||
const pid = t.parent_task_id || '';
|
||
if (!childMap[pid]) childMap[pid] = [];
|
||
childMap[pid].push(t);
|
||
}
|
||
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()));
|
||
return { allMains: mains, childMap };
|
||
}, [tasks]);
|
||
|
||
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
|
||
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): React.JSX.Element => {
|
||
const kids = childMap[node.id] || [];
|
||
const big = size === "lg";
|
||
const arrow = side === 'left'
|
||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
|
||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
|
||
const kidsContainer = kids.length > 0 ? (
|
||
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
|
||
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
|
||
</div>
|
||
) : null;
|
||
return (
|
||
<div key={node.id} className="flex flex-row items-center gap-1">
|
||
{side === 'left' && kidsContainer}
|
||
{side === 'left' && card}
|
||
{arrow}
|
||
{side === 'right' && card}
|
||
{side === 'right' && kidsContainer}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 🚀 焦点模式 vs 全景模式:共用同一套垂直时间线布局,仅数据过滤不同
|
||
const visibleMains = useMemo(() => {
|
||
if (showFullMap) return allMains;
|
||
// 焦点模式:保留自身活跃的主线,或含活跃分支(后代)的主线,
|
||
// 避免「主线已完成、但并发协助分支仍在进行中」时整条分支被错误隐藏
|
||
const hasActiveDescendant = (node: TaskResponse): boolean => {
|
||
const kids = childMap[node.id] || [];
|
||
return kids.some(k => active(k.status) || hasActiveDescendant(k));
|
||
};
|
||
return allMains.filter(t => active(t.status) || hasActiveDescendant(t));
|
||
}, [allMains, childMap, showFullMap]);
|
||
|
||
return (
|
||
<div>
|
||
{/* 模式切换 */}
|
||
<div className="mb-3 flex justify-center">
|
||
<button onClick={() => setShowFullMap(!showFullMap)}
|
||
className={`rounded-full bg-gray-100 font-medium text-gray-600 hover:bg-gray-200 transition-colors ${size === "lg" ? "px-5 py-2 text-sm" : "px-4 py-1.5 text-xs"}`}>
|
||
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
|
||
<div className="space-y-6">
|
||
{visibleMains.map((mainTask, mainIdx) => {
|
||
// 🔧 侧翼严格过滤:主线归主轴,仅 SPAWN 协助分支进入左右翼
|
||
const directChildren = (childMap[mainTask.id] || []).filter(c => !isMain(c));
|
||
const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status));
|
||
const leftDirect = directChildren.filter((_, i) => i % 2 === 0);
|
||
const rightDirect = directChildren.filter((_, i) => i % 2 === 1);
|
||
const lineStyle = hasLegacyActive && !active(mainTask.status);
|
||
|
||
return (
|
||
<div key={mainTask.id} className="relative">
|
||
{/* 中央垂直线:按行渲染;首行从卡片中间起,末行到卡片中间止,避免冒出/多截 */}
|
||
<div
|
||
className={`absolute left-1/2 -translate-x-1/2 z-0 bg-gray-200 ${size === "lg" ? "w-1" : "w-0.5"}`}
|
||
style={{
|
||
top: mainIdx === 0 ? "50%" : 0,
|
||
bottom: mainIdx === visibleMains.length - 1 ? "50%" : 0,
|
||
}}
|
||
/>
|
||
<div className="flex flex-row items-start w-full">
|
||
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
|
||
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
|
||
{leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))}
|
||
</div>
|
||
{/* 中央 */}
|
||
<div className="shrink-0 z-10 relative">
|
||
<SlimCard task={mainTask} active={active(mainTask.status)}
|
||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
|
||
{active(mainTask.status) && (
|
||
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
|
||
)}
|
||
</div>
|
||
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
|
||
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
|
||
{rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))}
|
||
</div>
|
||
</div>
|
||
|
||
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
|
||
<div className="flex justify-center py-2">
|
||
<span className={`text-gray-300 ${size === "lg" ? "text-sm" : "text-[10px]"}`}>▼</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{visibleMains.length === 0 && (
|
||
<p className={`text-center text-gray-400 py-8 ${size === "lg" ? "text-sm" : "text-xs"}`}>
|
||
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 记录弹窗 */}
|
||
{recordsTask && (
|
||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
|
||
<div className={`relative z-10 mx-4 max-h-[80vh] w-full overflow-y-auto rounded-xl bg-white shadow-2xl ${size === "lg" ? "max-w-2xl p-6" : "max-w-md p-5"}`}>
|
||
<div className={`mb-3 flex items-center justify-between`}><h3 className={`font-bold ${size === "lg" ? "text-lg" : "text-sm"}`}>提交记录 — {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400">暂无记录</p> :
|
||
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
|
||
<div key={r.id} className="flex gap-2">
|
||
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
|
||
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className={`text-gray-400 ${size === "lg" ? "text-xs" : "text-[10px]"}`}>{fmtTime(r.created_at)}</p>
|
||
{r.remark && <p className={`mt-0.5 text-gray-700 ${size === "lg" ? "text-sm" : "text-xs"}`}>{r.remark}</p>}
|
||
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return (
|
||
<Image.PreviewGroup>
|
||
<div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <Image key={j} src={imageUrl(img)} width={size === "lg" ? 96 : 56} height={size === "lg" ? 96 : 56} className="rounded border object-cover" style={{ objectFit: "cover" }} />)}</div>
|
||
</Image.PreviewGroup>
|
||
); })()}
|
||
</div>
|
||
</div>
|
||
))}</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default TaskFlowView;
|