feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善
This commit is contained in:
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"antd": "^6.5.3",
|
"antd": "^6.5.3",
|
||||||
@ -1714,6 +1715,33 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz",
|
||||||
|
"integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz",
|
||||||
|
"integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.11.1",
|
"version": "2.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"antd": "^6.5.3",
|
"antd": "^6.5.3",
|
||||||
|
|||||||
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;
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import TaskFlowView from "./TaskFlowView";
|
||||||
import {
|
import {
|
||||||
getTaskTree,
|
getTaskTree,
|
||||||
receiveTask,
|
receiveTask,
|
||||||
@ -17,59 +18,8 @@ import {
|
|||||||
transferTask,
|
transferTask,
|
||||||
} from "../../services/taskApi";
|
} from "../../services/taskApi";
|
||||||
import { useToast } from "../ui/Toast";
|
import { useToast } from "../ui/Toast";
|
||||||
import type { ProductScanResponse, TaskResponse, TaskStatus } from "../../types/api";
|
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
||||||
import { TASK_STATUS } from "../../types/api";
|
import { getStatusConfig } from "../../constants/task";
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 状态 → 颜色/标签映射
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 通用 Modal 容器
|
// 通用 Modal 容器
|
||||||
@ -116,7 +66,7 @@ const Modal = memo(function Modal({
|
|||||||
// 确认接收弹窗
|
// 确认接收弹窗
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
|
export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
|
||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
submitting,
|
submitting,
|
||||||
@ -169,7 +119,7 @@ const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
|
|||||||
// 品质驳回弹窗
|
// 品质驳回弹窗
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const RejectModal = memo(function RejectModal({
|
export const RejectModal = memo(function RejectModal({
|
||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
submitting,
|
submitting,
|
||||||
@ -250,7 +200,7 @@ const RejectModal = memo(function RejectModal({
|
|||||||
// 完工裂变转交弹窗
|
// 完工裂变转交弹窗
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const TransferModal = memo(function TransferModal({
|
export const TransferModal = memo(function TransferModal({
|
||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
submitting,
|
submitting,
|
||||||
@ -499,162 +449,14 @@ const TransferModal = memo(function TransferModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 单个任务节点卡片
|
// 操作弹窗目标类型
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
interface ModalTarget {
|
export interface ModalTarget {
|
||||||
task: TaskResponse;
|
task: TaskResponse;
|
||||||
action: "receive" | "reject" | "transfer";
|
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();
|
e?.preventDefault();
|
||||||
const trimmed = serial.trim();
|
const trimmed = serial.trim();
|
||||||
if (trimmed.length !== 16) {
|
if (trimmed.length !== 16) {
|
||||||
setError("请输入 16 位产品序列号");
|
setError("请输入 16 位产品身份证");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -702,7 +504,7 @@ export default function TaskTreeViewer() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg =
|
const msg =
|
||||||
err?.response?.status === 404
|
err?.response?.status === 404
|
||||||
? `未找到序列号 ${trimmed} 对应的产品`
|
? `未找到产品身份证 ${trimmed} 对应的产品`
|
||||||
: err?.response?.data?.detail ??
|
: err?.response?.data?.detail ??
|
||||||
err?.message ??
|
err?.message ??
|
||||||
"查询失败,请检查后端服务";
|
"查询失败,请检查后端服务";
|
||||||
@ -793,7 +595,7 @@ export default function TaskTreeViewer() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-xl font-bold text-gray-800">任务全景树</h2>
|
<h2 className="text-xl font-bold text-gray-800">任务全景树</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
输入 16 位产品序列号,查看完整任务流转十字矩阵树状图
|
输入 16 位产品身份证,查看完整任务流转十字矩阵树状图
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
|
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
|
||||||
@ -803,7 +605,7 @@ export default function TaskTreeViewer() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={serial}
|
value={serial}
|
||||||
onChange={(e) => setSerial(e.target.value)}
|
onChange={(e) => setSerial(e.target.value)}
|
||||||
placeholder="输入 16 位序列号,如 X20260801000001"
|
placeholder="输入 16 位产品身份证,如 X20260801000001"
|
||||||
maxLength={16}
|
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"
|
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="mb-6">
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
|
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
|
||||||
<div>
|
<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">
|
<p className="font-mono text-base font-bold tracking-widest text-gray-800">
|
||||||
{product.serial_number}
|
{product.serial_number}
|
||||||
</p>
|
</p>
|
||||||
@ -894,25 +696,15 @@ export default function TaskTreeViewer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- 任务树 ---- */}
|
{/* ---- 任务流转泳道/卡片视图 ---- */}
|
||||||
{product && !loading && (
|
{product && !loading && (
|
||||||
<>
|
<>
|
||||||
{product.task_tree && product.task_tree.length > 0 ? (
|
{product.task_tree && product.task_tree.length > 0 ? (
|
||||||
<div className="rounded-xl bg-white p-6 shadow-sm">
|
<div className="rounded-xl bg-white p-6 shadow-sm overflow-hidden">
|
||||||
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-gray-500">
|
<TaskFlowView
|
||||||
<GitBranch className="h-4 w-4" />
|
tasks={product.task_tree}
|
||||||
任务流转树状图
|
onAction={setModalTarget}
|
||||||
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">
|
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">
|
||||||
|
|||||||
@ -19,7 +19,7 @@ const MENU = [
|
|||||||
title: "任务全景",
|
title: "任务全景",
|
||||||
path: "/admin/tasks",
|
path: "/admin/tasks",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
description: "序列号查任务树 · 裂变/返工可视化",
|
description: "身份证查任务树 · 裂变/返工可视化",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { useEffect, useState } from "react";
|
||||||
import { ScanLine, ClipboardList, Bell, User } from "lucide-react";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { ScanLine, ClipboardList, Bell, User, ArrowLeft } from "lucide-react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { getNotifications } from "../../services/notificationApi";
|
||||||
|
|
||||||
/** 底部导航 Tab 配置 */
|
/** 底部导航 Tab 配置 — 与 uni-app pages.json tabBar.list 完全一致 */
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ path: "/scan", label: "扫码干活", icon: ScanLine },
|
{ path: "/scan", label: "扫码干活", icon: ScanLine },
|
||||||
{ path: "/tasks", label: "我的任务", icon: ClipboardList },
|
{ path: "/tasks", label: "我的任务", icon: ClipboardList },
|
||||||
@ -10,38 +13,92 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 导航栏高度(供页面计算偏移量) */
|
/** 导航栏高度(供页面计算偏移量) */
|
||||||
export const TAB_BAR_HEIGHT = 64; // px(h-16)
|
export const TAB_BAR_HEIGHT = 64;
|
||||||
|
const TOP_BAR_HEIGHT = 48;
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
// 🔔 未读消息数轮询
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = user?.username || user?.id || "";
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
getNotifications(userId, 0, 1)
|
||||||
|
.then((res) => setUnreadCount(res.unread_count || 0))
|
||||||
|
.catch(() => {}); // 静默
|
||||||
|
}
|
||||||
|
|
||||||
|
poll(); // 立即请求一次
|
||||||
|
const id = setInterval(poll, 30_000); // 每 30 秒轮询
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col bg-gray-50">
|
<div className="flex h-dvh flex-col bg-gray-50">
|
||||||
|
{/* ======== 顶部导航栏 — 返回管理端 ======== */}
|
||||||
|
<header
|
||||||
|
className="fixed top-0 left-0 right-0 z-50 flex items-center border-b border-gray-200 bg-white px-4 shadow-sm"
|
||||||
|
style={{ height: TOP_BAR_HEIGHT }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin/dashboard")}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 active:bg-blue-100"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} strokeWidth={2.5} />
|
||||||
|
返回管理端
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-xs text-gray-400">生产流转 · 移动端</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* ======== 主内容区 ======== */}
|
{/* ======== 主内容区 ======== */}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
paddingTop: TOP_BAR_HEIGHT,
|
||||||
|
paddingBottom: TAB_BAR_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ======== 底部导航栏 ======== */}
|
{/* ======== 底部导航栏 — 与 uni-app tabBar 完全一致 ======== */}
|
||||||
<nav
|
<nav
|
||||||
className="fixed bottom-0 z-50 w-full border-t border-gray-200 bg-white pb-safe"
|
className="fixed bottom-0 left-0 right-0 z-50 border-t border-gray-200 bg-white pb-safe"
|
||||||
style={{ height: TAB_BAR_HEIGHT }}
|
style={{ height: TAB_BAR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex h-full max-w-lg items-center justify-around">
|
<div className="mx-auto flex h-full max-w-lg items-center justify-around">
|
||||||
{TABS.map(({ path, label, icon: Icon }) => (
|
{TABS.map(({ path, label, icon: Icon }) => {
|
||||||
<NavLink
|
const isNotifyTab = path === "/notifications";
|
||||||
key={path}
|
return (
|
||||||
to={path}
|
<NavLink
|
||||||
className={({ isActive }) =>
|
key={path}
|
||||||
`flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
|
to={path}
|
||||||
isActive
|
className={({ isActive }) =>
|
||||||
? "text-blue-600"
|
`relative flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
|
||||||
: "text-gray-400 hover:text-gray-600"
|
isActive
|
||||||
}`
|
? "text-blue-600"
|
||||||
}
|
: "text-gray-400 hover:text-gray-600"
|
||||||
>
|
}`
|
||||||
<Icon size={22} strokeWidth={2} />
|
}
|
||||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
>
|
||||||
</NavLink>
|
<span className="relative">
|
||||||
))}
|
<Icon size={22} strokeWidth={2} />
|
||||||
|
{/* 🔴 消息 Tab 未读红点 */}
|
||||||
|
{isNotifyTab && unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-2 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-bold text-white shadow-[0_0_0_2px_white]">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
/** 手动输入序列号区域 */
|
/** 手动输入产品身份证区域 */
|
||||||
import { Search, Loader2 } from "lucide-react";
|
import { Search, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface ManualInputProps {
|
interface ManualInputProps {
|
||||||
@ -29,7 +29,7 @@ export default function ManualInput({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="手动输入16位序列号"
|
placeholder="手动输入16位产品身份证"
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
|
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,25 +1,7 @@
|
|||||||
/** 产品信息卡片 */
|
/** 产品信息卡片 */
|
||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import type { ProductScanResponse } from "../../types/api";
|
import type { ProductScanResponse } from "../../types/api";
|
||||||
import { TASK_STATUS } from "../../types/api";
|
import { statusColor, statusLabel } from "../../constants/task";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
[TASK_STATUS.PENDING]: "待接收",
|
|
||||||
[TASK_STATUS.WIP]: "进行中",
|
|
||||||
[TASK_STATUS.COMPLETED]: "已完成",
|
|
||||||
[TASK_STATUS.REJECTED]: "已驳回",
|
|
||||||
[TASK_STATUS.ARCHIVED]: "已入库",
|
|
||||||
};
|
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
|
|
||||||
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
|
|
||||||
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
|
|
||||||
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
|
|
||||||
default: return "bg-gray-100 text-gray-600";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: ProductScanResponse;
|
product: ProductScanResponse;
|
||||||
@ -32,12 +14,12 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
<Package className="h-5 w-5 text-blue-600" />
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
<h3 className="font-semibold text-gray-800">产品信息</h3>
|
<h3 className="font-semibold text-gray-800">产品信息</h3>
|
||||||
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(product.status)}`}>
|
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(product.status)}`}>
|
||||||
{STATUS_LABELS[product.status] ?? product.status}
|
{statusLabel(product.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-400">序列号</span>
|
<span className="text-gray-400">身份证</span>
|
||||||
<p className="font-mono font-medium text-gray-800">{product.serial_number}</p>
|
<p className="font-mono font-medium text-gray-800">{product.serial_number}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -2,25 +2,7 @@
|
|||||||
import { useMemo, memo } from "react";
|
import { useMemo, memo } from "react";
|
||||||
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
|
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
|
||||||
import type { TaskResponse, TaskSummary } from "../../types/api";
|
import type { TaskResponse, TaskSummary } from "../../types/api";
|
||||||
import { TASK_STATUS } from "../../types/api";
|
import { statusColor, statusLabel } from "../../constants/task";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
[TASK_STATUS.PENDING]: "待接收",
|
|
||||||
[TASK_STATUS.WIP]: "进行中",
|
|
||||||
[TASK_STATUS.COMPLETED]: "已完成",
|
|
||||||
[TASK_STATUS.REJECTED]: "已驳回",
|
|
||||||
[TASK_STATUS.ARCHIVED]: "已入库",
|
|
||||||
};
|
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
|
|
||||||
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
|
|
||||||
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
|
|
||||||
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
|
|
||||||
default: return "bg-gray-100 text-gray-600";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskListCardProps {
|
interface TaskListCardProps {
|
||||||
tasks: TaskResponse[];
|
tasks: TaskResponse[];
|
||||||
@ -55,7 +37,7 @@ const TaskNode = memo(function TaskNode({ task, depth }: TaskNodeProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
|
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
|
||||||
{STATUS_LABELS[task.status] ?? task.status}
|
{statusLabel(task.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.child_tasks?.map((child) => (
|
{task.child_tasks?.map((child) => (
|
||||||
|
|||||||
71
frontend/src/constants/task.ts
Normal file
71
frontend/src/constants/task.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 任务状态 → 颜色/标签映射(全局唯一数据源)
|
||||||
|
*
|
||||||
|
* 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用,
|
||||||
|
* 避免在多处重复维护相同的映射逻辑。
|
||||||
|
*/
|
||||||
|
import { TASK_STATUS } from "../types/api";
|
||||||
|
|
||||||
|
export interface StatusStyle {
|
||||||
|
bg: string;
|
||||||
|
text: string;
|
||||||
|
ring: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_CONFIG: Record<string, StatusStyle> = {
|
||||||
|
[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: "已完成",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK: StatusStyle = {
|
||||||
|
bg: "bg-gray-50",
|
||||||
|
text: "text-gray-600",
|
||||||
|
ring: "ring-gray-300",
|
||||||
|
label: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 获取状态的完整样式配置(bg / text / ring / label)— 大小写不敏感 */
|
||||||
|
export function getStatusConfig(status: string): StatusStyle {
|
||||||
|
const key = status?.toUpperCase?.() ?? status;
|
||||||
|
return STATUS_CONFIG[key] ?? { ...FALLBACK, label: status };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅获取 bg + text 类名,用于简单的状态标签着色 */
|
||||||
|
export function statusColor(status: string): string {
|
||||||
|
const cfg = getStatusConfig(status);
|
||||||
|
return `${cfg.bg} ${cfg.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅获取中文标签 */
|
||||||
|
export function statusLabel(status: string): string {
|
||||||
|
return getStatusConfig(status).label;
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ export interface UserInfo {
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean; // 初始化时检查 token
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue extends AuthState {
|
interface AuthContextValue extends AuthState {
|
||||||
@ -36,7 +36,8 @@ interface AuthContextValue extends AuthState {
|
|||||||
// Token 存储 key
|
// Token 存储 key
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const TOKEN_KEY = "track_admin_token";
|
const ACCESS_TOKEN_KEY = "track_admin_token";
|
||||||
|
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
|
||||||
const USER_KEY = "track_admin_user";
|
const USER_KEY = "track_admin_user";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -62,23 +63,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
loading: true,
|
loading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化:从 localStorage 恢复 token
|
// 初始化:从 localStorage 恢复双 Token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedToken = localStorage.getItem(TOKEN_KEY);
|
const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
const savedUser = localStorage.getItem(USER_KEY);
|
const savedUser = localStorage.getItem(USER_KEY);
|
||||||
if (savedToken && savedUser) {
|
if (savedToken && savedUser) {
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(savedUser) as UserInfo;
|
const user = JSON.parse(savedUser) as UserInfo;
|
||||||
setState({ user, token: savedToken, loading: false });
|
setState({ user, token: savedToken, loading: false });
|
||||||
// 可选:后端验证 token 是否仍有效
|
// 后台静默验证 token 是否仍有效
|
||||||
getMe(savedToken)
|
getMe(savedToken)
|
||||||
.then((fresh) => {
|
.then((fresh) => {
|
||||||
setState((prev) => ({ ...prev, user: fresh }));
|
setState((prev) => ({ ...prev, user: fresh }));
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
|
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
// token 过期,清除
|
// 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器
|
||||||
logoutInternal();
|
// 通过 Refresh Token 无感刷新,彻底失败才跳转登录
|
||||||
|
console.error(
|
||||||
|
"[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):",
|
||||||
|
err?.message ?? err,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
logoutInternal();
|
logoutInternal();
|
||||||
@ -89,26 +94,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function logoutInternal() {
|
function logoutInternal() {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
setState({ user: null, token: null, loading: false });
|
setState({ user: null, token: null, loading: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string) => {
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
const result = await loginApi(username, password);
|
const result = await loginApi(username, password);
|
||||||
const token = result.access_token;
|
const accessToken = result.access_token;
|
||||||
|
const refreshToken = result.refresh_token;
|
||||||
const user: UserInfo = result.user;
|
const user: UserInfo = result.user;
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
// 存储双 Token
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
setState({ user, token, loading: false });
|
setState({ user, token: accessToken, loading: false });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
logoutInternal();
|
logoutInternal();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🚀 稳定 Context value 引用,避免消费者不必要的 re-render
|
|
||||||
const ctxValue = useMemo<AuthContextValue>(
|
const ctxValue = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@ -1,29 +1,200 @@
|
|||||||
/** 待办 — 我的任务列表 */
|
/** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { ClipboardList } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../services/api";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import type { TaskSummary } from "../types/api";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 常量 — 与 uni-app 完全一致
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "all", label: "全部" },
|
||||||
|
{ key: "PENDING", label: "待接收" },
|
||||||
|
{ key: "WIP", label: "进行中" },
|
||||||
|
{ key: "COMPLETED", label: "已完成" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabKey = (typeof TABS)[number]["key"];
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
PENDING: "待接收",
|
||||||
|
WIP: "进行中",
|
||||||
|
COMPLETED: "已完成",
|
||||||
|
REJECTED: "已驳回",
|
||||||
|
ARCHIVED: "已入库",
|
||||||
|
CANCELED: "已撤回",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 状态 → Tailwind 颜色类名 */
|
||||||
|
function statusColor(s: string) {
|
||||||
|
switch (s) {
|
||||||
|
case "PENDING":
|
||||||
|
return "bg-yellow-50 text-yellow-700";
|
||||||
|
case "WIP":
|
||||||
|
return "bg-blue-50 text-blue-700";
|
||||||
|
case "COMPLETED":
|
||||||
|
return "bg-green-50 text-green-700";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-600";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string) {
|
||||||
|
if (!t) return "";
|
||||||
|
const d = new Date(t);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Page 组件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export default function MyTasksPage() {
|
export default function MyTasksPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<TabKey>("PENDING");
|
||||||
|
const [tasks, setTasks] = useState<TaskSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function fetchTasks() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const username = user?.username || "";
|
||||||
|
const { data } = await api.get<{ tasks: TaskSummary[]; total: number }>(
|
||||||
|
"/tasks/",
|
||||||
|
{ params: { assignee_id: username, limit: 100 } }
|
||||||
|
);
|
||||||
|
// 排序:主干任务在前 + 创建时间升序(与 uni-app 完全一致)
|
||||||
|
const sorted = (data.tasks || []).sort((a, b) => {
|
||||||
|
const aIsMain = !a.parent_task_id || a.task_type !== "SPAWN";
|
||||||
|
const bIsMain = !b.parent_task_id || b.task_type !== "SPAWN";
|
||||||
|
if (aIsMain && !bIsMain) return -1;
|
||||||
|
if (!aIsMain && bIsMain) return 1;
|
||||||
|
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||||
|
});
|
||||||
|
setTasks(sorted);
|
||||||
|
} catch {
|
||||||
|
setTasks([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 按 Tab 筛选 ----
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (tab === "all") return tasks;
|
||||||
|
return tasks.filter((t) => t.status === tab);
|
||||||
|
}, [tasks, tab]);
|
||||||
|
|
||||||
|
// ---- 各 Tab 计数 ----
|
||||||
|
function countBy(key: TabKey) {
|
||||||
|
if (key === "all") return tasks.length;
|
||||||
|
return tasks.filter((t) => t.status === key).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(task: TaskSummary) {
|
||||||
|
const sn = task.product_sn || task.product_id;
|
||||||
|
if (sn) navigate(`/scan?serial=${sn}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// 渲染
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||||
|
{/* 标题 */}
|
||||||
<h2 className="text-xl font-bold text-gray-800">我的任务</h2>
|
<h2 className="text-xl font-bold text-gray-800">我的任务</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">待处理和进行中的任务</p>
|
|
||||||
|
|
||||||
{/* 占位空状态 */}
|
{/* Tab 栏 — 与 uni-app 样式一致:sticky、可横向滚动 */}
|
||||||
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
|
<div className="sticky top-0 z-10 -mx-4 overflow-x-auto bg-gray-50 px-4 py-3">
|
||||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
<div className="flex gap-2 whitespace-nowrap">
|
||||||
<svg
|
{TABS.map((t) => (
|
||||||
className="h-10 w-10 text-gray-400"
|
<button
|
||||||
fill="none"
|
key={t.key}
|
||||||
stroke="currentColor"
|
onClick={() => setTab(t.key)}
|
||||||
viewBox="0 0 24 24"
|
className={`rounded-full px-4 py-2 text-[13px] font-semibold transition-colors ${
|
||||||
>
|
tab === t.key
|
||||||
<path
|
? "bg-blue-600 text-white"
|
||||||
strokeLinecap="round"
|
: "bg-white text-gray-500 hover:bg-gray-100"
|
||||||
strokeLinejoin="round"
|
}`}
|
||||||
strokeWidth={1.5}
|
>
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
{t.label} ({countBy(t.key)})
|
||||||
/>
|
</button>
|
||||||
</svg>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-gray-400">暂无待办任务</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 加载中 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-1 items-center justify-center py-20 text-gray-400">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center py-20">
|
||||||
|
<span className="text-6xl">📋</span>
|
||||||
|
<span className="mt-3 text-sm text-gray-400">
|
||||||
|
{tab === "all" ? "暂无待办任务" : "无此状态任务"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 任务卡片列表 */}
|
||||||
|
{!loading &&
|
||||||
|
filtered.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => goDetail(task)}
|
||||||
|
className="mb-2.5 cursor-pointer rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
{/* Row 1: 类型标签 + 任务名 | 状态 */}
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{/* 主干/协助 标签 — 与 uni-app tag-badge 一致 */}
|
||||||
|
<span
|
||||||
|
className={`inline-block shrink-0 rounded px-1.5 py-px text-[11px] font-bold ${
|
||||||
|
task.task_type === "SPAWN"
|
||||||
|
? "bg-purple-100 text-purple-700"
|
||||||
|
: "bg-blue-100 text-blue-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.task_type === "SPAWN" ? "协助" : "主干"}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[15px] font-bold text-gray-800">
|
||||||
|
{task.task_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${statusColor(task.status)}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[task.status] || task.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: 身份证 + 物料 */}
|
||||||
|
<div className="flex gap-3 text-xs text-gray-500">
|
||||||
|
<span>身份证: {task.product_sn || "—"}</span>
|
||||||
|
<span>物料: {task.product_material || "—"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: 创建时间 */}
|
||||||
|
<div className="mt-1 text-[11px] text-gray-400">
|
||||||
|
创建: {formatTime(task.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,164 @@
|
|||||||
/** 消息 — 通知推送 */
|
/** 消息通知 — 1:1 复刻 uni-app pages/notify/index.vue + 真实 API */
|
||||||
export default function NotificationsPage() {
|
import { useEffect, useState } from "react";
|
||||||
return (
|
import { useNavigate } from "react-router-dom";
|
||||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
<h2 className="text-xl font-bold text-gray-800">消息通知</h2>
|
import {
|
||||||
<p className="mt-1 text-sm text-gray-500">任务流转和系统通知</p>
|
getNotifications,
|
||||||
|
markNotificationRead,
|
||||||
|
type NotificationItem,
|
||||||
|
} from "../services/notificationApi";
|
||||||
|
|
||||||
{/* 占位空状态 */}
|
// ============================================================
|
||||||
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
|
// 类型常量
|
||||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
// ============================================================
|
||||||
<svg
|
|
||||||
className="h-10 w-10 text-gray-400"
|
const TYPE_CONFIG: Record<string, { icon: string; title: string }> = {
|
||||||
fill="none"
|
TRANSFER: { icon: "🟢", title: "新任务派发" },
|
||||||
stroke="currentColor"
|
REJECT: { icon: "🔴", title: "品质驳回提醒" },
|
||||||
viewBox="0 0 24 24"
|
};
|
||||||
>
|
|
||||||
<path
|
function formatTime(t: string) {
|
||||||
strokeLinecap="round"
|
if (!t) return "";
|
||||||
strokeLinejoin="round"
|
const d = new Date(t);
|
||||||
strokeWidth={1.5}
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
/>
|
}
|
||||||
</svg>
|
|
||||||
</div>
|
// ============================================================
|
||||||
<p className="mt-4 text-sm text-gray-400">暂无新消息</p>
|
// Page 组件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function fetchNotifications() {
|
||||||
|
const userId = user?.username || user?.id || "";
|
||||||
|
if (!userId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getNotifications(userId);
|
||||||
|
setNotifications(res.notifications || []);
|
||||||
|
} catch {
|
||||||
|
setNotifications([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCardTap(item: NotificationItem) {
|
||||||
|
// 标记已读
|
||||||
|
if (!item.is_read) {
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id);
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === item.id ? { ...n, is_read: true } : n))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转详情页
|
||||||
|
if (item.task_id) {
|
||||||
|
navigate(`/scan?taskId=${item.task_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// 渲染
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col px-4 pt-safe pb-20">
|
||||||
|
{/* 标题区 — 与 uni-app 完全一致 */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">消息通知</h2>
|
||||||
|
<p className="mt-1 text-[13px] text-gray-400">任务流转和系统通知</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 加载中 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-1 items-center justify-center py-20 text-sm text-gray-400">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && notifications.length === 0 && (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center pb-24">
|
||||||
|
<span className="text-[64px] leading-none">🔔</span>
|
||||||
|
<span className="mt-3 text-sm text-gray-400">暂无新消息</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 通知列表 */}
|
||||||
|
{!loading && notifications.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{notifications.map((item) => {
|
||||||
|
const cfg = TYPE_CONFIG[item.type] || {
|
||||||
|
icon: "📌",
|
||||||
|
title: "系统通知",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleCardTap(item)}
|
||||||
|
className={`flex cursor-pointer items-start gap-2.5 rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-all active:scale-[0.98] ${
|
||||||
|
!item.is_read
|
||||||
|
? "border-l-[3px] border-l-blue-600 shadow-[0_1px_6px_rgba(37,99,235,0.1)]"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 左侧:未读红点 + 类型图标 */}
|
||||||
|
<div className="flex w-7 shrink-0 flex-col items-center gap-1">
|
||||||
|
{!item.is_read && (
|
||||||
|
<span className="h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-xl leading-none ${
|
||||||
|
item.is_read ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:内容 */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-[15px] font-semibold ${
|
||||||
|
item.is_read ? "text-gray-600" : "font-bold text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cfg.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-gray-400">
|
||||||
|
{formatTime(item.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] leading-relaxed text-gray-500 break-all">
|
||||||
|
{item.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="mt-1.5 shrink-0 text-xl text-gray-300">›</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,71 @@
|
|||||||
/** 我的 — 个人中心 */
|
/** 我的 — 1:1 复刻 uni-app pages/profile/index.vue + 真实用户数据绑定 */
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// ---- 从 AuthContext 派生真实数据 ----
|
||||||
|
const displayName = user?.display_name || user?.username || "未知用户";
|
||||||
|
const avatarChar = displayName.charAt(0);
|
||||||
|
const roleLabel =
|
||||||
|
user?.role === "SUPER_ADMIN"
|
||||||
|
? "超级管理员"
|
||||||
|
: user?.role || "操作员";
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
<div className="flex min-h-full flex-col px-4 pt-safe pb-20">
|
||||||
{/* 用户信息卡片 */}
|
{/* 用户卡片 — 与 uni-app user-card 完全一致 */}
|
||||||
<div className="mt-4 flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
|
<div className="mt-4 flex items-center gap-3 rounded-xl bg-white p-4 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
{/* 头像 — 取真实用户名的第一个字 */}
|
||||||
张
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
||||||
|
{avatarChar}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-gray-800">张三</h3>
|
<span className="block text-base font-bold text-gray-800">
|
||||||
<p className="text-sm text-gray-500">操作员</p>
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{roleLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span className="text-xl text-gray-300">›</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 菜单列表占位 */}
|
{/* 菜单列表 — 与 uni-app menu-card 完全一致 */}
|
||||||
<div className="mt-6 space-y-1 rounded-xl bg-white shadow-sm">
|
<div className="mt-4 overflow-hidden rounded-xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||||
{["工作统计", "设置", "帮助与反馈", "关于"].map((item) => (
|
{["工作统计", "设置", "帮助与反馈", "关于"].map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
className="flex items-center justify-between border-b border-gray-50 px-4 py-3 last:border-b-0"
|
className={`flex items-center justify-between px-4 py-3.5 ${
|
||||||
|
i < 3 ? "border-b border-gray-50" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-gray-700">{item}</span>
|
<span className="text-sm text-gray-700">{item}</span>
|
||||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span className="text-xl text-gray-300">›</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 退出登录 — 红色高亮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center justify-between border-t border-gray-50 px-4 py-3.5 transition-colors hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm text-red-500">
|
||||||
|
<LogOut size={16} strokeWidth={2} />
|
||||||
|
退出登录
|
||||||
|
</span>
|
||||||
|
<span className="text-xl text-red-300">›</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 版本号 — 与 uni-app .version 一致 */}
|
||||||
|
<p className="mt-8 text-center text-xs text-gray-300">生产流转 v1.0.0</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
|
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, Suspense, lazy } from "react";
|
||||||
import { QrCode } from "lucide-react";
|
import { QrCode, Loader2 } from "lucide-react";
|
||||||
import { scanProduct } from "../services/productApi";
|
import { scanProduct } from "../services/productApi";
|
||||||
import type { ProductScanResponse } from "../types/api";
|
import type { ProductScanResponse } from "../types/api";
|
||||||
import CameraScanner from "../components/scan/CameraScanner";
|
|
||||||
import ManualInput from "../components/scan/ManualInput";
|
import ManualInput from "../components/scan/ManualInput";
|
||||||
import QueryResult from "../components/scan/QueryResult";
|
import QueryResult from "../components/scan/QueryResult";
|
||||||
|
|
||||||
|
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
|
||||||
|
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
|
||||||
|
|
||||||
export default function ScanPage() {
|
export default function ScanPage() {
|
||||||
const [cameraActive, setCameraActive] = useState(false);
|
const [cameraActive, setCameraActive] = useState(false);
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||||
@ -18,7 +20,7 @@ export default function ScanPage() {
|
|||||||
|
|
||||||
/** 通用查询 */
|
/** 通用查询 */
|
||||||
const doQuery = useCallback(async (sn: string) => {
|
const doQuery = useCallback(async (sn: string) => {
|
||||||
if (sn.length < 8) { setError("序列号至少需要 8 位"); return; }
|
if (sn.length < 8) { setError("产品身份证至少需要 8 位"); return; }
|
||||||
setSerialNumber(sn);
|
setSerialNumber(sn);
|
||||||
setLastScanned(sn);
|
setLastScanned(sn);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -37,7 +39,7 @@ export default function ScanPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** 扫码回调:提取纯序列号 */
|
/** 扫码回调:提取纯产品身份证 */
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
(decodedText: string) => {
|
(decodedText: string) => {
|
||||||
setCameraError(null);
|
setCameraError(null);
|
||||||
@ -53,15 +55,23 @@ export default function ScanPage() {
|
|||||||
<h2 className="text-lg font-bold text-gray-800">扫码干活</h2>
|
<h2 className="text-lg font-bold text-gray-800">扫码干活</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 摄像头扫码 */}
|
{/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
|
||||||
<div className="mt-3 px-4">
|
<div className="mt-3 px-4">
|
||||||
<CameraScanner
|
<Suspense
|
||||||
active={cameraActive}
|
fallback={
|
||||||
onToggle={setCameraActive}
|
<div className="flex items-center justify-center rounded-xl border border-gray-200 bg-white py-16 shadow-sm">
|
||||||
onScan={handleScan}
|
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||||
error={cameraError}
|
</div>
|
||||||
onError={setCameraError}
|
}
|
||||||
/>
|
>
|
||||||
|
<CameraScanner
|
||||||
|
active={cameraActive}
|
||||||
|
onToggle={setCameraActive}
|
||||||
|
onScan={handleScan}
|
||||||
|
error={cameraError}
|
||||||
|
onError={setCameraError}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 手动输入 */}
|
{/* 手动输入 */}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
|
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import type { ProductResponse } from "../../types/admin";
|
import type { ProductResponse } from "../../types/admin";
|
||||||
import CreateProductDialog from "./CreateProductDialog";
|
import CreateProductDialog from "./CreateProductDialog";
|
||||||
@ -26,6 +27,35 @@ export default function AdminProductsPage() {
|
|||||||
const [printCopies, setPrintCopies] = useState(1);
|
const [printCopies, setPrintCopies] = useState(1);
|
||||||
const [printing, setPrinting] = useState(false);
|
const [printing, setPrinting] = useState(false);
|
||||||
|
|
||||||
|
// 🚀 虚拟滚动:响应式列数 + 行虚拟化
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [columns, setColumns] = useState(5);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const calc = () => {
|
||||||
|
const w = el.clientWidth;
|
||||||
|
if (w >= 1280) setColumns(6);
|
||||||
|
else if (w >= 1024) setColumns(5);
|
||||||
|
else if (w >= 768) setColumns(4);
|
||||||
|
else if (w >= 640) setColumns(3);
|
||||||
|
else setColumns(2);
|
||||||
|
};
|
||||||
|
calc();
|
||||||
|
const ro = new ResizeObserver(calc);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [products.length > 0]);
|
||||||
|
|
||||||
|
const rowCount = Math.ceil(products.length / columns);
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: rowCount,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 320,
|
||||||
|
overscan: 2,
|
||||||
|
});
|
||||||
|
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -124,7 +154,7 @@ export default function AdminProductsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-800">产品管理</h2>
|
<h2 className="text-xl font-bold text-gray-800">产品管理</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
查看所有产品序列号并生成二维码用于打印标签
|
查看所有产品身份证并生成二维码用于打印标签
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -176,38 +206,74 @@ export default function AdminProductsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && products.length > 0 && (
|
{!loading && products.length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<div
|
||||||
{products.map((p) => (
|
ref={scrollRef}
|
||||||
<div
|
className="h-[calc(100vh-220px)] overflow-auto rounded-xl"
|
||||||
key={p.id}
|
>
|
||||||
className="flex flex-col items-center rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
|
<div
|
||||||
>
|
style={{
|
||||||
{/* 二维码 */}
|
height: rowVirtualizer.getTotalSize(),
|
||||||
<img
|
position: "relative",
|
||||||
src={`${QR_BASE}/${p.serial_number}`}
|
}}
|
||||||
alt={`QR-${p.serial_number}`}
|
>
|
||||||
className="h-40 w-40 rounded-lg border border-gray-100"
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
loading="lazy"
|
const start = virtualRow.index * columns;
|
||||||
/>
|
const rowProducts = products.slice(start, start + columns);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: virtualRow.size,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
className="grid gap-4 px-1"
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
>
|
||||||
|
{/* 动态 inline grid 列数(Tailwind 不支持动态 columns) */}
|
||||||
|
<div
|
||||||
|
className="grid gap-4"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{rowProducts.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-col items-center rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
{/* 二维码 */}
|
||||||
|
<img
|
||||||
|
src={`${QR_BASE}/${p.serial_number}`}
|
||||||
|
alt={`QR-${p.serial_number}`}
|
||||||
|
className="h-40 w-40 rounded-lg border border-gray-100"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 序列号 */}
|
{/* 产品身份证 */}
|
||||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">
|
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">
|
||||||
{p.serial_number}
|
{p.serial_number}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-gray-400">
|
<p className="mt-0.5 text-xs text-gray-400">
|
||||||
{p.order_no ?? "—"}
|
{p.order_no ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 打印按钮 */}
|
{/* 打印按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPrint(p)}
|
onClick={() => handleOpenPrint(p)}
|
||||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 hover:border-blue-300"
|
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 hover:border-blue-300"
|
||||||
>
|
>
|
||||||
<Printer className="h-3.5 w-3.5" />
|
<Printer className="h-3.5 w-3.5" />
|
||||||
打印标签
|
打印标签
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,474 @@
|
|||||||
import TaskTreeViewer from "../../components/TaskTree/TaskTreeViewer";
|
/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||||
|
Warehouse, GitBranch, X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import api from "../../services/api";
|
||||||
|
import { scanProduct } from "../../services/productApi";
|
||||||
|
import {
|
||||||
|
receiveTask, rejectTask, transferTask,
|
||||||
|
} from "../../services/taskApi";
|
||||||
|
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
||||||
|
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||||
|
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||||
|
import type { ProductResponse } from "../../types/admin";
|
||||||
|
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
||||||
|
import { useToast } from "../../components/ui/Toast";
|
||||||
|
import { getStatusConfig } from "../../constants/task";
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ key: "", label: "全部" },
|
||||||
|
{ key: "PENDING", label: "待接收" },
|
||||||
|
{ key: "WIP", label: "进行中" },
|
||||||
|
{ key: "COMPLETED", label: "已完成" },
|
||||||
|
{ key: "ARCHIVED", label: "已入库" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface OrderGroup {
|
||||||
|
orderNo: string;
|
||||||
|
products: ProductResponse[];
|
||||||
|
allInWarehouse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminTasksPage() {
|
export default function AdminTasksPage() {
|
||||||
return <TaskTreeViewer />;
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 搜索 & 筛选
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 手风琴展开状态
|
||||||
|
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 任务树展开
|
||||||
|
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
|
||||||
|
const [taskTrees, setTaskTrees] = useState<Record<string, ProductScanResponse | null>>({});
|
||||||
|
const [treeLoading, setTreeLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 弹窗
|
||||||
|
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// ---- 加载产品列表 ----
|
||||||
|
async function loadProducts(kw: string, st: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { limit: 1000 };
|
||||||
|
if (kw.trim()) params.keyword = kw.trim();
|
||||||
|
if (st) params.status = st;
|
||||||
|
const { data } = await api.get<ProductResponse[]>("/products/", { params });
|
||||||
|
setProducts(data);
|
||||||
|
} catch {
|
||||||
|
setError("加载产品列表失败,请检查后端服务");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次加载
|
||||||
|
useEffect(() => {
|
||||||
|
loadProducts(keyword, statusFilter);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 🚀 状态筛选 Tab 变化时自动重新查询
|
||||||
|
useEffect(() => {
|
||||||
|
loadProducts(keyword, statusFilter);
|
||||||
|
}, [statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
function handleSearch(e?: React.FormEvent) {
|
||||||
|
e?.preventDefault();
|
||||||
|
setExpandedOrders(new Set());
|
||||||
|
setExpandedProducts(new Set());
|
||||||
|
setTaskTrees({});
|
||||||
|
loadProducts(keyword, statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 按订单分组 ----
|
||||||
|
const orderGroups = useMemo<OrderGroup[]>(() => {
|
||||||
|
const map = new Map<string, ProductResponse[]>();
|
||||||
|
for (const p of products) {
|
||||||
|
const key = p.order_no || "未绑定订单";
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(p);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([orderNo, prods]) => ({
|
||||||
|
orderNo,
|
||||||
|
products: prods,
|
||||||
|
allInWarehouse: prods.every(
|
||||||
|
(p) => p.current_location_id === "virtual_warehouse"
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
// ---- 手风琴切换 ----
|
||||||
|
function toggleOrder(orderNo: string) {
|
||||||
|
setExpandedOrders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(orderNo)) next.delete(orderNo);
|
||||||
|
else next.add(orderNo);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 任务树展开/收起 ----
|
||||||
|
async function toggleProductTree(serialNumber: string) {
|
||||||
|
setExpandedProducts((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(serialNumber)) {
|
||||||
|
next.delete(serialNumber);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
// 加载任务树
|
||||||
|
next.add(serialNumber);
|
||||||
|
if (!taskTrees[serialNumber]) {
|
||||||
|
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
|
||||||
|
scanProduct(serialNumber)
|
||||||
|
.then((result) => {
|
||||||
|
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
const msg =
|
||||||
|
err?.response?.data?.detail ?? err?.message ?? "加载任务树失败";
|
||||||
|
toast(msg, "error");
|
||||||
|
setExpandedProducts((prev2) => {
|
||||||
|
const n2 = new Set(prev2);
|
||||||
|
n2.delete(serialNumber);
|
||||||
|
return n2;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setTreeLoading((s) => ({ ...s, [serialNumber]: false }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 任务操作 ----
|
||||||
|
const refreshProductTree = useCallback(async (serialNumber: string) => {
|
||||||
|
try {
|
||||||
|
const result = await scanProduct(serialNumber);
|
||||||
|
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
|
||||||
|
} catch { /* 静默 */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleReceive() {
|
||||||
|
if (!modalTarget) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await receiveTask(modalTarget.task.id);
|
||||||
|
toast("任务已接收", "success");
|
||||||
|
const sn = modalTarget.task.product_sn || "";
|
||||||
|
setModalTarget(null);
|
||||||
|
if (sn) await refreshProductTree(sn);
|
||||||
|
await loadProducts(keyword, statusFilter);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(reason: string) {
|
||||||
|
if (!modalTarget) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await rejectTask(modalTarget.task.id, reason);
|
||||||
|
toast("任务已驳回", "success");
|
||||||
|
const sn = modalTarget.task.product_sn || "";
|
||||||
|
setModalTarget(null);
|
||||||
|
if (sn) await refreshProductTree(sn);
|
||||||
|
await loadProducts(keyword, statusFilter);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransfer(
|
||||||
|
nextTaskName: string,
|
||||||
|
assignees: string[],
|
||||||
|
note: string,
|
||||||
|
) {
|
||||||
|
if (!modalTarget) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await transferTask(
|
||||||
|
modalTarget.task.id, assignees, nextTaskName, note || undefined,
|
||||||
|
);
|
||||||
|
toast(result.message, "success");
|
||||||
|
const sn = modalTarget.task.product_sn || "";
|
||||||
|
setModalTarget(null);
|
||||||
|
if (sn) await refreshProductTree(sn);
|
||||||
|
await loadProducts(keyword, statusFilter);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 渲染 ----
|
||||||
|
const modalTask = modalTarget?.task ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ---- 标题 ---- */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">任务全景 Dashboard</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
按订单聚合查看产品流转状态,支持多维搜索与状态筛选
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 搜索 + 筛选 ---- */}
|
||||||
|
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-xl">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
placeholder="搜索产品身份证、订单号、规格型号..."
|
||||||
|
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 状态筛选 Tabs */}
|
||||||
|
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
|
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 错误 ---- */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 加载中 ---- */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 空态 ---- */}
|
||||||
|
{!loading && products.length === 0 && !error && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<Package className="mb-3 h-12 w-12" />
|
||||||
|
<p>暂无产品数据</p>
|
||||||
|
<p className="mt-1 text-sm">创建产品并绑定订单后,在此查看流转状态</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 订单手风琴列表 ---- */}
|
||||||
|
{!loading && orderGroups.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{orderGroups.map((group) => {
|
||||||
|
const isOpen = expandedOrders.has(group.orderNo);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.orderNo}
|
||||||
|
className="overflow-hidden rounded-xl bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
{/* 订单头部 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleOrder(group.orderNo)}
|
||||||
|
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Package className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{group.orderNo === "未绑定订单"
|
||||||
|
? "📋 未绑定订单"
|
||||||
|
: `📦 订单: ${group.orderNo}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{group.products.length} 个产品
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 入库状态标签 */}
|
||||||
|
{group.allInWarehouse && group.products.length > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">
|
||||||
|
<Warehouse className="h-3 w-3" />
|
||||||
|
已全部入库
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!group.allInWarehouse && (
|
||||||
|
<span className="text-xs text-gray-400">流转中</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 订单展开内容 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-gray-100">
|
||||||
|
{/* 表头 */}
|
||||||
|
<div className="grid grid-cols-12 gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
|
||||||
|
<div className="col-span-2">产品身份证</div>
|
||||||
|
<div className="col-span-2">规格型号</div>
|
||||||
|
<div className="col-span-1">宏观状态</div>
|
||||||
|
<div className="col-span-1">任务状态</div>
|
||||||
|
<div className="col-span-2">当前位置</div>
|
||||||
|
<div className="col-span-2">创建时间</div>
|
||||||
|
<div className="col-span-2">操作</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 产品行 */}
|
||||||
|
{group.products.map((p) => {
|
||||||
|
const productExpanded = expandedProducts.has(p.serial_number);
|
||||||
|
const isTreeLoading = treeLoading[p.serial_number];
|
||||||
|
const tree = taskTrees[p.serial_number];
|
||||||
|
const statusCfg = getStatusConfig(p.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={p.id}>
|
||||||
|
<div className="grid grid-cols-12 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
|
||||||
|
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider">
|
||||||
|
{p.serial_number}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-500 truncate">
|
||||||
|
{p.spec_model || p.material_name || p.material_id || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{p.overall_status || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}
|
||||||
|
>
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-500 truncate">
|
||||||
|
{p.current_location_id === "virtual_warehouse" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-purple-600">
|
||||||
|
🏭 仓库
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
p.current_location_id ?? "—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-400">
|
||||||
|
{new Date(p.created_at).toLocaleDateString("zh-CN")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleProductTree(p.serial_number)}
|
||||||
|
className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isTreeLoading ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : productExpanded ? (
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{productExpanded ? "收起" : "流转树"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开的流转树 — 卡片堆叠视图 */}
|
||||||
|
{productExpanded && tree && (
|
||||||
|
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
|
||||||
|
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
流转卡片 — {p.serial_number}
|
||||||
|
</h4>
|
||||||
|
{tree.task_tree && tree.task_tree.length > 0 ? (
|
||||||
|
<TaskFlowView
|
||||||
|
tasks={tree.task_tree}
|
||||||
|
onAction={setModalTarget}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
|
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
|
||||||
|
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{productExpanded && isTreeLoading && (
|
||||||
|
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
|
||||||
|
<p className="mt-2 text-xs text-gray-400">加载流转树...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 弹窗 ---- */}
|
||||||
|
<ReceiveConfirmModal
|
||||||
|
open={modalTarget?.action === "receive"}
|
||||||
|
task={modalTask}
|
||||||
|
submitting={submitting}
|
||||||
|
onClose={() => setModalTarget(null)}
|
||||||
|
onConfirm={handleReceive}
|
||||||
|
/>
|
||||||
|
<RejectModal
|
||||||
|
open={modalTarget?.action === "reject"}
|
||||||
|
task={modalTask}
|
||||||
|
submitting={submitting}
|
||||||
|
onClose={() => setModalTarget(null)}
|
||||||
|
onSubmit={handleReject}
|
||||||
|
/>
|
||||||
|
<TransferModal
|
||||||
|
open={modalTarget?.action === "transfer"}
|
||||||
|
task={modalTask}
|
||||||
|
submitting={submitting}
|
||||||
|
onClose={() => setModalTarget(null)}
|
||||||
|
onSubmit={handleTransfer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -453,11 +453,11 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* 系统唯一 ID(只读) */}
|
{/* 产品身份证 — 系统自动生成 16 位 HEX(不可编辑) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
系统唯一 ID <span className="text-xs text-gray-400">(自动生成)</span>
|
产品身份证 <span className="text-xs text-gray-400">(自动生成)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value="提交后自动生成 16 位 HEX"
|
value="提交后自动生成 16 位 HEX"
|
||||||
@ -466,7 +466,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 产品序列号(选填) */}
|
{/* 产品序列号 — 用户自定义录入(选填) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
产品序列号 <span className="text-xs text-gray-400">(选填)</span>
|
产品序列号 <span className="text-xs text-gray-400">(选填)</span>
|
||||||
|
|||||||
@ -1,4 +1,39 @@
|
|||||||
import axios from "axios";
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Token 存储 Key
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "track_admin_token";
|
||||||
|
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
|
||||||
|
const USER_KEY = "track_admin_user";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Token 读写工具
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(accessToken: string, refreshToken: string) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Axios 实例
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
@ -8,10 +43,13 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 请求拦截器 — 注入 JWT token
|
// ============================================================
|
||||||
|
// 请求拦截器 — 注入 Access Token
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem("track_admin_token");
|
const token = getAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
@ -20,19 +58,104 @@ api.interceptors.request.use(
|
|||||||
(error) => Promise.reject(error),
|
(error) => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 响应拦截器 — 统一错误处理
|
// ============================================================
|
||||||
|
// 响应拦截器 — 双 Token 无感刷新 + 并发请求队列
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let retryQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
/** 处理队列中的所有挂起请求 */
|
||||||
|
function processQueue(error: unknown, token: string | null) {
|
||||||
|
retryQueue.forEach((p) => {
|
||||||
|
if (token) {
|
||||||
|
p.resolve(token);
|
||||||
|
} else {
|
||||||
|
p.reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
retryQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
const originalRequest: AxiosRequestConfig & { _retry?: boolean } =
|
||||||
// Token 过期或无效 → 清除并跳转登录
|
error.config;
|
||||||
localStorage.removeItem("track_admin_token");
|
const status = error.response?.status;
|
||||||
localStorage.removeItem("track_admin_user");
|
|
||||||
|
// 仅处理 401
|
||||||
|
if (status !== 401) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过登录和刷新接口自身(避免死循环)
|
||||||
|
const url = originalRequest.url ?? "";
|
||||||
|
if (url.includes("/auth/login") || url.includes("/auth/refresh")) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免对同一请求重复刷新
|
||||||
|
if (originalRequest._retry) {
|
||||||
|
clearAuth();
|
||||||
if (window.location.pathname.startsWith("/admin")) {
|
if (window.location.pathname.startsWith("/admin")) {
|
||||||
window.location.href = "/admin/login";
|
window.location.href = "/admin/login";
|
||||||
}
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
// ---- 并发请求队列机制 ----
|
||||||
|
if (isRefreshing) {
|
||||||
|
// 已有刷新进行中,加入队列等待
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
retryQueue.push({ resolve, reject });
|
||||||
|
}).then((newToken) => {
|
||||||
|
originalRequest.headers = originalRequest.headers || {};
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error("无 Refresh Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用刷新接口
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAccessToken: string = data.access_token;
|
||||||
|
setTokens(newAccessToken, refreshToken); // 更新 Access Token
|
||||||
|
|
||||||
|
// 重放队列中的所有请求
|
||||||
|
processQueue(null, newAccessToken);
|
||||||
|
|
||||||
|
// 重试当前请求
|
||||||
|
originalRequest.headers = originalRequest.headers || {};
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh Token 也过期 — 彻底登出
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
clearAuth();
|
||||||
|
if (window.location.pathname.startsWith("/admin")) {
|
||||||
|
window.location.href = "/admin/login";
|
||||||
|
}
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import axios from "axios";
|
/** 认证 API — 登录、刷新 Token、获取用户信息 */
|
||||||
import type { UserInfo } from "../contexts/AuthContext";
|
import type { UserInfo } from "../contexts/AuthContext";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录 — 注意:此请求不走 axios 实例(避免循环依赖),直接用 fetch */
|
export interface RefreshResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录 — 注意:此请求不走 axios 实例,直接用 fetch */
|
||||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -22,6 +28,19 @@ export async function login(username: string, password: string): Promise<LoginRe
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 刷新 Access Token — 注意:不走 axios 实例,直接用 fetch */
|
||||||
|
export async function refreshAccessToken(refreshToken: string): Promise<RefreshResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Refresh Token 无效或已过期");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/** 验证 token 有效性 + 获取最新用户信息 */
|
/** 验证 token 有效性 + 获取最新用户信息 */
|
||||||
export async function getMe(token: string): Promise<UserInfo> {
|
export async function getMe(token: string): Promise<UserInfo> {
|
||||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||||
|
|||||||
41
frontend/src/services/notificationApi.ts
Normal file
41
frontend/src/services/notificationApi.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/** 消息通知 API */
|
||||||
|
import api from "./api";
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type: "TRANSFER" | "REJECT";
|
||||||
|
task_id: string | null;
|
||||||
|
is_read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
notifications: NotificationItem[];
|
||||||
|
total: number;
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户的通知列表 */
|
||||||
|
export async function getNotifications(
|
||||||
|
userId: string,
|
||||||
|
skip = 0,
|
||||||
|
limit = 20
|
||||||
|
): Promise<NotificationListResponse> {
|
||||||
|
const { data } = await api.get<NotificationListResponse>("/notifications/", {
|
||||||
|
params: { user_id: userId, skip, limit },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记单条通知为已读 */
|
||||||
|
export async function markNotificationRead(
|
||||||
|
notificationId: string
|
||||||
|
): Promise<NotificationItem> {
|
||||||
|
const { data } = await api.put<NotificationItem>(
|
||||||
|
`/notifications/${notificationId}/read`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import api from "./api";
|
import api from "./api";
|
||||||
import type { ProductScanResponse } from "../types/api";
|
import type { ProductScanResponse } from "../types/api";
|
||||||
|
|
||||||
/** 扫码查询 — 根据 16 位序列号查产品 + 顶层任务 */
|
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
|
||||||
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
|
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
|
||||||
const { data } = await api.get<ProductScanResponse>(
|
const { data } = await api.get<ProductScanResponse>(
|
||||||
`/products/scan/${encodeURIComponent(serialNumber)}`
|
`/products/scan/${encodeURIComponent(serialNumber)}`
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface ProductResponse {
|
|||||||
material_type: string | null;
|
material_type: string | null;
|
||||||
parent_product_id: string | null;
|
parent_product_id: string | null;
|
||||||
current_location_id: string | null;
|
current_location_id: string | null;
|
||||||
|
overall_status: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,12 +21,16 @@ export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
|
|||||||
export interface TaskSummary {
|
export interface TaskSummary {
|
||||||
id: string;
|
id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
|
product_sn: string;
|
||||||
|
product_material: string;
|
||||||
parent_task_id: string | null;
|
parent_task_id: string | null;
|
||||||
task_name: string;
|
task_name: string;
|
||||||
assignee_id: string | null;
|
assignee_id: string | null;
|
||||||
status: TaskStatus | string;
|
status: TaskStatus | string;
|
||||||
notify_parent_on_complete: boolean;
|
notify_parent_on_complete: boolean;
|
||||||
is_rework: boolean;
|
is_rework: boolean;
|
||||||
|
task_type: string | null;
|
||||||
|
remark: string | null;
|
||||||
reject_reason: string | null;
|
reject_reason: string | null;
|
||||||
received_at: string | null;
|
received_at: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
@ -35,6 +39,16 @@ export interface TaskSummary {
|
|||||||
|
|
||||||
export interface TaskResponse extends TaskSummary {
|
export interface TaskResponse extends TaskSummary {
|
||||||
child_tasks: TaskResponse[];
|
child_tasks: TaskResponse[];
|
||||||
|
records: TaskRecordResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRecordResponse {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
action: string;
|
||||||
|
operator_id: string | null;
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 操作响应 ----
|
// ---- 操作响应 ----
|
||||||
|
|||||||
@ -11,10 +11,11 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks(id: string) {
|
||||||
"vendor-react": ["react", "react-dom", "react-router-dom"],
|
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/scheduler")) return "vendor-react";
|
||||||
"vendor-antd": ["antd", "@ant-design/icons"],
|
if (id.includes("node_modules/react-router")) return "vendor-react";
|
||||||
"vendor-qrcode": ["html5-qrcode"],
|
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) return "vendor-antd";
|
||||||
|
if (id.includes("node_modules/html5-qrcode")) return "vendor-qrcode";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user