feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善

This commit is contained in:
2026-08-07 11:44:12 +08:00
parent b71c5a2d07
commit 721cfe1504
25 changed files with 1730 additions and 435 deletions

View 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;

View File

@ -10,6 +10,7 @@ import {
Warehouse,
UserPlus,
} from "lucide-react";
import TaskFlowView from "./TaskFlowView";
import {
getTaskTree,
receiveTask,
@ -17,59 +18,8 @@ import {
transferTask,
} from "../../services/taskApi";
import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse, TaskStatus } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
// ============================================================
// 状态 → 颜色/标签映射
// ============================================================
const STATUS_CONFIG: Record<
string,
{ bg: string; text: string; ring: string; label: string }
> = {
[TASK_STATUS.PENDING]: {
bg: "bg-yellow-50",
text: "text-yellow-700",
ring: "ring-yellow-400",
label: "待接收",
},
[TASK_STATUS.WIP]: {
bg: "bg-blue-50",
text: "text-blue-700",
ring: "ring-blue-400",
label: "进行中",
},
[TASK_STATUS.COMPLETED]: {
bg: "bg-green-50",
text: "text-green-700",
ring: "ring-green-400",
label: "已完成",
},
[TASK_STATUS.REJECTED]: {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
label: "已驳回",
},
[TASK_STATUS.ARCHIVED]: {
bg: "bg-gray-50",
text: "text-gray-600",
ring: "ring-gray-300",
label: "已入库",
},
};
function getStatusConfig(status: string) {
return (
STATUS_CONFIG[status] ?? {
bg: "bg-gray-50",
text: "text-gray-600",
ring: "ring-gray-300",
label: status,
}
);
}
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
// ============================================================
// 通用 Modal 容器
@ -116,7 +66,7 @@ const Modal = memo(function Modal({
// 确认接收弹窗
// ============================================================
const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
open,
task,
submitting,
@ -169,7 +119,7 @@ const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
// 品质驳回弹窗
// ============================================================
const RejectModal = memo(function RejectModal({
export const RejectModal = memo(function RejectModal({
open,
task,
submitting,
@ -250,7 +200,7 @@ const RejectModal = memo(function RejectModal({
// 完工裂变转交弹窗
// ============================================================
const TransferModal = memo(function TransferModal({
export const TransferModal = memo(function TransferModal({
open,
task,
submitting,
@ -499,162 +449,14 @@ const TransferModal = memo(function TransferModal({
});
// ============================================================
// 单个任务节点卡片
// 操作弹窗目标类型
// ============================================================
interface ModalTarget {
export interface ModalTarget {
task: TaskResponse;
action: "receive" | "reject" | "transfer";
}
const TaskNodeCard = memo(function TaskNodeCard({
task,
isLast,
onAction,
}: {
task: TaskResponse;
isLast: boolean;
onAction: (target: ModalTarget) => void;
}) {
const { bg, text, ring, label } = getStatusConfig(task.status);
return (
<div className="relative">
{/* 树形连接线 */}
{task.child_tasks.length > 0 && (
<>
<div
className="absolute left-4 top-full z-0 w-px bg-gray-200"
style={{ height: "calc(100% - 2rem)" }}
/>
{task.child_tasks.length > 1 && (
<div
className="absolute left-4 z-0 h-px bg-gray-200"
style={{
top: "calc(100% + 1rem)",
width: "calc(50% - 1rem)",
}}
/>
)}
</>
)}
{/* 卡片本体 */}
<div
className={`relative z-10 mb-1 rounded-lg border bg-white px-3 py-2.5 shadow-sm transition-shadow hover:shadow-md ${ring} ${
task.is_rework ? "ring-2 ring-red-500" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
{/* 左侧:任务名 + 标签 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{task.is_rework && (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-bold text-white animate-pulse">
⚠ 返工
</span>
)}
{task.child_tasks.length > 1 && (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
<GitBranch className="h-2.5 w-2.5" />
裂变×{task.child_tasks.length}
</span>
)}
<span className="truncate text-sm font-semibold text-gray-800">
{task.task_name}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
{task.assignee_id && <span>负责人: {task.assignee_id}</span>}
{task.received_at && (
<span>
接收: {new Date(task.received_at).toLocaleDateString("zh-CN")}
</span>
)}
{task.completed_at && (
<span>
完成:{" "}
{new Date(task.completed_at).toLocaleDateString("zh-CN")}
</span>
)}
</div>
{task.reject_reason && (
<p className="mt-1 text-[11px] text-red-500">
驳回原因: {task.reject_reason}
</p>
)}
</div>
{/* 右侧:状态标签 + 操作按钮 */}
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${bg} ${text}`}
>
{label}
</span>
{/* 操作按钮 */}
{task.status === TASK_STATUS.PENDING && (
<div className="flex gap-1">
<button
className="rounded border border-blue-200 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 transition-colors"
onClick={() => onAction({ task, action: "receive" })}
>
接收
</button>
<button
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
onClick={() => onAction({ task, action: "reject" })}
>
驳回
</button>
<button
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
onClick={() => onAction({ task, action: "transfer" })}
>
转交
</button>
</div>
)}
{task.status === TASK_STATUS.WIP && (
<div className="flex gap-1">
<button
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
onClick={() => onAction({ task, action: "reject" })}
>
驳回
</button>
<button
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
onClick={() => onAction({ task, action: "transfer" })}
>
转交
</button>
</div>
)}
</div>
</div>
</div>
{/* 递归渲染子任务 */}
{task.child_tasks.length > 0 && (
<div className="ml-8 border-l-2 border-gray-100 pl-4 pt-1">
{task.child_tasks.map((child, idx) => (
<TaskNodeCard
key={child.id}
task={child}
isLast={idx === task.child_tasks.length - 1}
onAction={onAction}
/>
))}
</div>
)}
</div>
);
});
// ============================================================
// 主容器组件
// ============================================================
@ -688,7 +490,7 @@ export default function TaskTreeViewer() {
e?.preventDefault();
const trimmed = serial.trim();
if (trimmed.length !== 16) {
setError("请输入 16 位产品序列号");
setError("请输入 16 位产品身份证");
return;
}
@ -702,7 +504,7 @@ export default function TaskTreeViewer() {
} catch (err: any) {
const msg =
err?.response?.status === 404
? `未找到序列号 ${trimmed} 对应的产品`
? `未找到产品身份证 ${trimmed} 对应的产品`
: err?.response?.data?.detail ??
err?.message ??
"查询失败,请检查后端服务";
@ -793,7 +595,7 @@ export default function TaskTreeViewer() {
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800">任务全景树</h2>
<p className="mt-1 text-sm text-gray-500">
输入 16 位产品序列号,查看完整任务流转十字矩阵树状图
输入 16 位产品身份证,查看完整任务流转十字矩阵树状图
</p>
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
@ -803,7 +605,7 @@ export default function TaskTreeViewer() {
type="text"
value={serial}
onChange={(e) => setSerial(e.target.value)}
placeholder="输入 16 位序列号,如 X20260801000001"
placeholder="输入 16 位产品身份证,如 X20260801000001"
maxLength={16}
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 font-mono text-sm tracking-widest placeholder:tracking-normal placeholder:font-sans focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
@ -855,7 +657,7 @@ export default function TaskTreeViewer() {
<div className="mb-6">
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
<div>
<span className="text-xs text-gray-400">产品序列号</span>
<span className="text-xs text-gray-400">产品身份证</span>
<p className="font-mono text-base font-bold tracking-widest text-gray-800">
{product.serial_number}
</p>
@ -894,25 +696,15 @@ export default function TaskTreeViewer() {
</div>
)}
{/* ---- 任务树 ---- */}
{/* ---- 任务流转泳道/卡片视图 ---- */}
{product && !loading && (
<>
{product.task_tree && product.task_tree.length > 0 ? (
<div className="rounded-xl bg-white p-6 shadow-sm">
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-gray-500">
<GitBranch className="h-4 w-4" />
任务流转树状图
</h3>
<div className="space-y-1">
{product.task_tree.map((task, idx) => (
<TaskNodeCard
key={task.id}
task={task}
isLast={idx === product.task_tree!.length - 1}
onAction={setModalTarget}
/>
))}
</div>
<div className="rounded-xl bg-white p-6 shadow-sm overflow-hidden">
<TaskFlowView
tasks={product.task_tree}
onAction={setModalTarget}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">