feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善
This commit is contained in:
288
frontend/src/components/TaskTree/TaskFlowView.tsx
Normal file
288
frontend/src/components/TaskTree/TaskFlowView.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 任务流转卡片堆叠视图 — 水平泳道 + 卡片层叠布局
|
||||
*
|
||||
* 将递归任务树按层级拆分为水平泳道,每层卡片横向排列,
|
||||
* 当前激活(WIP/PENDING)卡片高亮居中,操作按钮集成在卡片底部。
|
||||
*/
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
GitBranch,
|
||||
ArrowDown,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
} 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 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 LevelGroup {
|
||||
depth: number;
|
||||
tasks: TaskResponse[];
|
||||
}
|
||||
|
||||
// ---- 将递归树拍平为层级 ----
|
||||
|
||||
function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] {
|
||||
const result: LevelGroup[] = [];
|
||||
if (!tasks || tasks.length === 0) return result;
|
||||
|
||||
// 当前层级
|
||||
result.push({ depth, tasks });
|
||||
|
||||
// 递归子层级
|
||||
for (const t of tasks) {
|
||||
if (t.child_tasks && t.child_tasks.length > 0) {
|
||||
const childLevels = 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;
|
||||
}
|
||||
|
||||
/** 按 depth 聚合所有层级 */
|
||||
function groupByDepth(levels: LevelGroup[]): Map<number, TaskResponse[]> {
|
||||
const map = new Map<number, TaskResponse[]>();
|
||||
for (const lvl of levels) {
|
||||
if (!map.has(lvl.depth)) map.set(lvl.depth, []);
|
||||
map.get(lvl.depth)!.push(...lvl.tasks);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---- 单张任务卡片 ----
|
||||
|
||||
const FlowCard = memo(function FlowCard({
|
||||
task,
|
||||
isActive,
|
||||
onAction,
|
||||
}: {
|
||||
task: TaskResponse;
|
||||
isActive: boolean;
|
||||
onAction: (target: ModalTarget) => void;
|
||||
}) {
|
||||
const cfg = getStatusConfig(task.status);
|
||||
const dwell = calcDwell(task.received_at, task.completed_at, task.status);
|
||||
const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED;
|
||||
const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative shrink-0 w-52 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-150 opacity-70"
|
||||
: "border-gray-200 hover:shadow-lg"
|
||||
} ${task.is_rework ? "border-l-red-500 border-l-4" : ""}`}
|
||||
>
|
||||
{/* 返工标记 */}
|
||||
{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">
|
||||
<AlertTriangle className="inline h-2.5 w-2.5 mr-0.5" />返工
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 裂变标记 */}
|
||||
{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">
|
||||
<GitBranch className="inline h-2.5 w-2.5 mr-0.5" />
|
||||
裂变×{task.child_tasks.length}
|
||||
</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}`}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
{task.assignee_id && (
|
||||
<span className="text-[10px] text-gray-400 truncate max-w-[80px]">
|
||||
{task.assignee_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 停留耗时 */}
|
||||
{dwell && (
|
||||
<p className={`mt-1 flex items-center gap-1 text-[10px] ${dwell.highlight ? "text-red-500 font-semibold" : "text-orange-500"}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{dwell.highlight ? <span className="animate-pulse">⏳ {dwell.text}</span> : <span>{dwell.text}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 驳回原因 */}
|
||||
{task.reject_reason && (
|
||||
<p className="mt-1 text-[10px] text-red-500 line-clamp-2">{task.reject_reason}</p>
|
||||
)}
|
||||
|
||||
{/* 日期 */}
|
||||
<p className="mt-1 text-[9px] text-gray-300">
|
||||
{task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`}
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 — 仅激活态显示 */}
|
||||
{isActive && (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ---- 主视图 ----
|
||||
|
||||
interface TaskFlowViewProps {
|
||||
tasks: TaskResponse[];
|
||||
onAction: (target: ModalTarget) => void;
|
||||
}
|
||||
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction }: TaskFlowViewProps) {
|
||||
// 按 depth 分组的层级数据
|
||||
const depthMap = useMemo(() => {
|
||||
if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>();
|
||||
const flat = 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]);
|
||||
|
||||
const depths = Array.from(depthMap.keys()).sort((a, b) => a - b);
|
||||
|
||||
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>
|
||||
<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;
|
||||
return (
|
||||
<div key={task.id} style={{ scrollSnapAlign: "start" }}>
|
||||
<FlowCard
|
||||
task={task}
|
||||
isActive={isActive}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 层级间连接箭头 */}
|
||||
{depth < depths.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<ArrowDown className="h-4 w-4 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskFlowView;
|
||||
Reference in New Issue
Block a user