refactor: Web端FlowCard与移动端100%对齐—主线/分支标签/备注/记录/时间格式/三种权限模式
This commit is contained in:
@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 任务流转卡片堆叠视图 — 水平分支 + 卡片层叠布局
|
* 任务流转卡片堆叠视图 — 水平分支 + 卡片层叠布局
|
||||||
*
|
*
|
||||||
* 将递归任务树按层级拆分为水平分支,每层卡片横向排列,
|
* 与移动端 TaskSwipeCards.vue 核心逻辑完全对齐:
|
||||||
* 当前激活(WIP/PENDING)卡片高亮居中,操作按钮集成在卡片底部。
|
* - 任务树按 TRANSFER/SPAWN 分类为主线/分支
|
||||||
|
* - 状态标签、时间格式、卡片元素一致
|
||||||
|
* - 三种权限模式(本人/管理员/只读)
|
||||||
*/
|
*/
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
@ -10,83 +12,72 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
Flag,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TaskResponse } from "../../types/api";
|
import type { TaskResponse } from "../../types/api";
|
||||||
import { TASK_STATUS } from "../../types/api";
|
import { TASK_STATUS } from "../../types/api";
|
||||||
import { getStatusConfig } from "../../constants/task";
|
import { getStatusConfig } from "../../constants/task";
|
||||||
import type { ModalTarget } from "./TaskTreeViewer";
|
import type { ModalTarget } from "./TaskTreeViewer";
|
||||||
|
|
||||||
// ---- 耗时计算(复用) ----
|
// ============================================================
|
||||||
|
// 工具函数 — 与移动端 fmtTime/statusLabel 完全一致
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
function calcDwell(
|
function fmtTime(d: string | null) {
|
||||||
receivedAt: string | null,
|
if (!d) return "";
|
||||||
completedAt: string | null,
|
const dt = new Date(d);
|
||||||
status: string,
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
): { text: string; highlight: boolean } | null {
|
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||||
if (!receivedAt) return null;
|
|
||||||
const start = new Date(receivedAt).getTime();
|
|
||||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
||||||
const diffMs = end - start;
|
|
||||||
if (diffMs < 0) return null;
|
|
||||||
|
|
||||||
const totalMin = Math.floor(diffMs / 60000);
|
|
||||||
if (totalMin < 1) return { text: "< 1分钟", highlight: status === "WIP" };
|
|
||||||
if (totalMin < 60) return { text: `${totalMin}分钟`, highlight: status === "WIP" };
|
|
||||||
|
|
||||||
const hours = Math.floor(totalMin / 60);
|
|
||||||
const remainMin = totalMin % 60;
|
|
||||||
if (hours < 24) {
|
|
||||||
return { text: `${hours}小时${remainMin > 0 ? remainMin + "分钟" : ""}`, highlight: status === "WIP" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const days = Math.floor(hours / 24);
|
/** 判断任务是否为主线(与移动端 isMain 逻辑一致) */
|
||||||
const remainHr = hours % 24;
|
function isMainTask(t: TaskResponse): boolean {
|
||||||
return { text: `${days}天${remainHr > 0 ? remainHr + "小时" : ""}`, highlight: status === "WIP" };
|
return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"
|
||||||
|
|| (!t.task_type && !!t.parent_task_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 类型 ----
|
// ============================================================
|
||||||
|
// 类型
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
interface LevelGroup {
|
interface LevelGroup {
|
||||||
depth: number;
|
depth: number;
|
||||||
tasks: TaskResponse[];
|
tasks: TaskResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 将递归树拍平为层级 ----
|
// ============================================================
|
||||||
|
// 任务树解析 — 按 depth 拍平(与移动端 lanes 逻辑结构等价)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] {
|
function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] {
|
||||||
const result: LevelGroup[] = [];
|
const result: LevelGroup[] = [];
|
||||||
if (!tasks || tasks.length === 0) return result;
|
if (!tasks || tasks.length === 0) return result;
|
||||||
|
|
||||||
// 当前层级
|
|
||||||
result.push({ depth, tasks });
|
result.push({ depth, tasks });
|
||||||
|
|
||||||
// 递归子层级
|
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.child_tasks && t.child_tasks.length > 0) {
|
if (t.child_tasks && t.child_tasks.length > 0) {
|
||||||
const childLevels = flattenLevels(t.child_tasks, depth + 1);
|
result.push(...flattenLevels(t.child_tasks, depth + 1));
|
||||||
for (const cl of childLevels) {
|
|
||||||
// 合并同深度的层级
|
|
||||||
const existing = result.find((r) => r.depth === cl.depth && r !== result[result.indexOf({ depth, tasks })] );
|
|
||||||
// 简化:直接 push,在渲染时按 depth 分组
|
|
||||||
}
|
|
||||||
result.push(...childLevels);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 depth 聚合所有层级 */
|
|
||||||
function groupByDepth(levels: LevelGroup[]): Map<number, TaskResponse[]> {
|
function groupByDepth(levels: LevelGroup[]): Map<number, TaskResponse[]> {
|
||||||
const map = new Map<number, TaskResponse[]>();
|
const map = new Map<number, TaskResponse[]>();
|
||||||
for (const lvl of levels) {
|
for (const lvl of levels) {
|
||||||
if (!map.has(lvl.depth)) map.set(lvl.depth, []);
|
if (!map.has(lvl.depth)) map.set(lvl.depth, []);
|
||||||
map.get(lvl.depth)!.push(...lvl.tasks);
|
const existing = map.get(lvl.depth)!;
|
||||||
|
for (const t of lvl.tasks) {
|
||||||
|
if (!existing.find((e) => e.id === t.id)) existing.push(t);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 单张任务卡片 ----
|
// ============================================================
|
||||||
|
// 单张任务卡片 — 与移动端 ss-card 完全对齐
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
const FlowCard = memo(function FlowCard({
|
const FlowCard = memo(function FlowCard({
|
||||||
task,
|
task,
|
||||||
@ -105,70 +96,129 @@ const FlowCard = memo(function FlowCard({
|
|||||||
const dwell = calcDwell(task.received_at, task.completed_at, task.status);
|
const dwell = calcDwell(task.received_at, task.completed_at, task.status);
|
||||||
const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED;
|
const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED;
|
||||||
const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED;
|
const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED;
|
||||||
// 权限:本人或管理员/主管可操作
|
const isCanceled = task.status?.toUpperCase() === "CANCELED";
|
||||||
|
const main = isMainTask(task);
|
||||||
|
|
||||||
|
// 权限:本人或管理员
|
||||||
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
|
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
|
||||||
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
|
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
|
||||||
const canOperate = isOwner || isManager;
|
const canOperate = isOwner || isManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative shrink-0 w-52 rounded-xl border-2 bg-white p-3.5 shadow-md transition-all ${
|
className={`relative shrink-0 w-56 rounded-xl border-2 bg-white p-3.5 shadow-md transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? `border-blue-400 ${cfg.ring} shadow-lg shadow-blue-100 scale-105 z-10`
|
? `border-blue-400 ${cfg.ring} shadow-lg shadow-blue-100 scale-105 z-10`
|
||||||
: isCompleted || isArchived
|
: isCompleted || isArchived
|
||||||
? "border-gray-150 opacity-70"
|
? "border-gray-200 opacity-70"
|
||||||
|
: isCanceled
|
||||||
|
? "border-gray-200 opacity-50"
|
||||||
: "border-gray-200 hover:shadow-lg"
|
: "border-gray-200 hover:shadow-lg"
|
||||||
} ${task.is_rework ? "border-l-red-500 border-l-4" : ""}`}
|
} ${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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{main ? "主分支" : "分支"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 返工/入库标记 — 与移动端 tag-rework-sm 一致 */}
|
||||||
|
<div className="mb-1 flex flex-wrap gap-1">
|
||||||
{task.is_rework && (
|
{task.is_rework && (
|
||||||
<div className="absolute -top-2 -left-1 rounded bg-red-600 px-1.5 py-0.5 text-[9px] font-bold text-white animate-pulse">
|
<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="inline h-2.5 w-2.5 mr-0.5" />返工
|
<AlertTriangle className="mr-0.5 h-2.5 w-2.5" />返工
|
||||||
</div>
|
</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 && (
|
{task.child_tasks.length > 1 && (
|
||||||
<div className="absolute -top-2 right-2 rounded bg-purple-100 px-1.5 py-0.5 text-[9px] font-medium text-purple-700">
|
<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="inline h-2.5 w-2.5 mr-0.5" />
|
<GitBranch className="mr-0.5 h-2.5 w-2.5" />
|
||||||
裂变×{task.child_tasks.length}
|
裂变×{task.child_tasks.length}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* 任务名 */}
|
|
||||||
<p className="text-sm font-bold text-gray-800 truncate">{task.task_name}</p>
|
|
||||||
|
|
||||||
{/* 状态 Badge */}
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${cfg.bg} ${cfg.text}`}>
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${cfg.bg} ${cfg.text}`}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
{task.assignee_id && (
|
|
||||||
<span className="text-[10px] text-gray-400 truncate max-w-[80px]">
|
|
||||||
{assigneeName || task.assignee_id}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
<p className="mt-0.5 text-[11px] text-gray-700 leading-relaxed">{task.remark}</p>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 停留耗时 — 与移动端逻辑一致 */}
|
||||||
{dwell && (
|
{dwell && (
|
||||||
<p className={`mt-1 flex items-center gap-1 text-[10px] ${dwell.highlight ? "text-red-500 font-semibold" : "text-orange-500"}`}>
|
<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" />
|
<Clock className="h-3 w-3" />
|
||||||
{dwell.highlight ? <span className="animate-pulse">⏳ {dwell.text}</span> : <span>{dwell.text}</span>}
|
{dwell.highlight ? <span className="animate-pulse">⏳ 停留: {dwell.text}</span> : <span>耗时: {dwell.text}</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 驳回原因 */}
|
{/* 驳回原因 */}
|
||||||
{task.reject_reason && (
|
{task.reject_reason && (
|
||||||
<p className="mt-1 text-[10px] text-red-500 line-clamp-2">{task.reject_reason}</p>
|
<p className="mt-1 text-[10px] text-red-500 line-clamp-2">驳回原因: {task.reject_reason}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 日期 */}
|
{/* 状态标记 — 与移动端 tc-stats 一致 */}
|
||||||
<p className="mt-1 text-[9px] text-gray-300">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`}
|
{task.received_at && (
|
||||||
</p>
|
<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 && (
|
{isActive && canOperate && (
|
||||||
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
|
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
|
||||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
|
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
|
||||||
@ -201,11 +251,9 @@ const FlowCard = memo(function FlowCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 非本人但管理员可见:干预提示 */}
|
|
||||||
{isActive && !isOwner && isManager && (
|
{isActive && !isOwner && isManager && (
|
||||||
<div className="mt-2 border-t border-orange-100 pt-2">
|
<div className="mt-2 border-t border-orange-100 pt-2">
|
||||||
<p className="text-[9px] text-orange-500 mb-1">⚠ 运维干预模式</p>
|
<p className="mb-1 text-[9px] text-orange-500">⚠ 运维干预模式</p>
|
||||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
|
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button onClick={() => onAction({ task, action: "reject" })}
|
<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">
|
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
|
||||||
@ -216,32 +264,45 @@ const FlowCard = memo(function FlowCard({
|
|||||||
强制转交
|
强制转交
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{task.status?.toUpperCase() === TASK_STATUS.WIP && (
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 非本人非管理员:只读提示 */}
|
|
||||||
{isActive && !canOperate && (
|
{isActive && !canOperate && (
|
||||||
<div className="mt-2 border-t border-gray-100 pt-2">
|
<div className="mt-2 border-t border-gray-100 pt-2">
|
||||||
<p className="text-[9px] text-gray-400 text-center">非当前任务责任人,无法操作</p>
|
<p className="text-center text-[9px] text-gray-400">非当前任务责任人,无法操作</p>
|
||||||
</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();
|
||||||
|
const diffMs = end - start;
|
||||||
|
if (diffMs < 0) return null;
|
||||||
|
const totalMin = Math.floor(diffMs / 60000);
|
||||||
|
if (totalMin < 1) return { text: "< 1分钟", highlight: status === "WIP" };
|
||||||
|
if (totalMin < 60) return { text: `${totalMin}分钟`, highlight: status === "WIP" };
|
||||||
|
const hours = Math.floor(totalMin / 60);
|
||||||
|
const remainMin = totalMin % 60;
|
||||||
|
if (hours < 24) return { text: `${hours}小时${remainMin > 0 ? remainMin + "分钟" : ""}`, highlight: status === "WIP" };
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const remainHr = hours % 24;
|
||||||
|
return { text: `${days}天${remainHr > 0 ? remainHr + "小时" : ""}`, highlight: status === "WIP" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主视图
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
interface TaskFlowViewProps {
|
interface TaskFlowViewProps {
|
||||||
tasks: TaskResponse[];
|
tasks: TaskResponse[];
|
||||||
@ -251,28 +312,12 @@ interface TaskFlowViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
|
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
|
||||||
// 按 depth 分组的层级数据
|
|
||||||
const depthMap = useMemo(() => {
|
const depthMap = useMemo(() => {
|
||||||
if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>();
|
if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>();
|
||||||
const flat = flattenLevels(tasks);
|
return groupByDepth(flattenLevels(tasks));
|
||||||
// 按 depth 聚合去重
|
|
||||||
const merged = new Map<number, Map<string, TaskResponse>>();
|
|
||||||
for (const lvl of flat) {
|
|
||||||
if (!merged.has(lvl.depth)) merged.set(lvl.depth, new Map());
|
|
||||||
const inner = merged.get(lvl.depth)!;
|
|
||||||
for (const t of lvl.tasks) {
|
|
||||||
if (!inner.has(t.id)) inner.set(t.id, t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = new Map<number, TaskResponse[]>();
|
|
||||||
for (const [depth, idMap] of merged) {
|
|
||||||
result.set(depth, Array.from(idMap.values()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const depths = Array.from(depthMap.keys()).sort((a, b) => a - b);
|
const depths = Array.from(depthMap.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
if (depths.length === 0) return null;
|
if (depths.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -281,17 +326,13 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
|||||||
const levelTasks = depthMap.get(depth) || [];
|
const levelTasks = depthMap.get(depth) || [];
|
||||||
return (
|
return (
|
||||||
<div key={depth}>
|
<div key={depth}>
|
||||||
{/* 层级标签 */}
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||||
{depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`}
|
{depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-px flex-1 bg-gray-100" />
|
<div className="h-px flex-1 bg-gray-100" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 pl-2" style={{ scrollSnapType: "x mandatory" }}>
|
||||||
{/* 卡片横向排列 */}
|
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2 pl-2"
|
|
||||||
style={{ scrollSnapType: "x mandatory" }}>
|
|
||||||
{levelTasks.map((task) => {
|
{levelTasks.map((task) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
task.status?.toUpperCase() === TASK_STATUS.PENDING ||
|
task.status?.toUpperCase() === TASK_STATUS.PENDING ||
|
||||||
@ -309,8 +350,6 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 层级间连接箭头 */}
|
|
||||||
{depth < depths.length - 1 && (
|
{depth < depths.length - 1 && (
|
||||||
<div className="flex justify-center py-1">
|
<div className="flex justify-center py-1">
|
||||||
<ArrowDown className="h-4 w-4 text-gray-300" />
|
<ArrowDown className="h-4 w-4 text-gray-300" />
|
||||||
|
|||||||
Reference in New Issue
Block a user