feat: Web端401队列拦截器 + 消息/任务/个人中心重写 + TabBar红点 + Admin页面完善
This commit is contained in:
@ -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() {
|
||||
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 (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
{/* 标题 */}
|
||||
<h2 className="text-xl font-bold text-gray-800">我的任务</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">待处理和进行中的任务</p>
|
||||
|
||||
{/* 占位空状态 */}
|
||||
<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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
{/* Tab 栏 — 与 uni-app 样式一致:sticky、可横向滚动 */}
|
||||
<div className="sticky top-0 z-10 -mx-4 overflow-x-auto bg-gray-50 px-4 py-3">
|
||||
<div className="flex gap-2 whitespace-nowrap">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-full px-4 py-2 text-[13px] font-semibold transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-500 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{t.label} ({countBy(t.key)})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">暂无待办任务</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,29 +1,164 @@
|
||||
/** 消息 — 通知推送 */
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
<h2 className="text-xl font-bold text-gray-800">消息通知</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">任务流转和系统通知</p>
|
||||
/** 消息通知 — 1:1 复刻 uni-app pages/notify/index.vue + 真实 API */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">暂无新消息</p>
|
||||
// ============================================================
|
||||
// 类型常量
|
||||
// ============================================================
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: string; title: string }> = {
|
||||
TRANSFER: { icon: "🟢", title: "新任务派发" },
|
||||
REJECT: { icon: "🔴", title: "品质驳回提醒" },
|
||||
};
|
||||
|
||||
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 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>
|
||||
|
||||
{/* 加载中 */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
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 (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
{/* 用户信息卡片 */}
|
||||
<div className="mt-4 flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
|
||||
<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 min-h-full flex-col px-4 pt-safe pb-20">
|
||||
{/* 用户卡片 — 与 uni-app user-card 完全一致 */}
|
||||
<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-12 w-12 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
||||
{avatarChar}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-800">张三</h3>
|
||||
<p className="text-sm text-gray-500">操作员</p>
|
||||
<span className="block text-base font-bold text-gray-800">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{roleLabel}</span>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-xl text-gray-300">›</span>
|
||||
</div>
|
||||
|
||||
{/* 菜单列表占位 */}
|
||||
<div className="mt-6 space-y-1 rounded-xl bg-white shadow-sm">
|
||||
{["工作统计", "设置", "帮助与反馈", "关于"].map((item) => (
|
||||
{/* 菜单列表 — 与 uni-app menu-card 完全一致 */}
|
||||
<div className="mt-4 overflow-hidden rounded-xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
{["工作统计", "设置", "帮助与反馈", "关于"].map((item, i) => (
|
||||
<div
|
||||
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>
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-xl text-gray-300">›</span>
|
||||
</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>
|
||||
|
||||
{/* 版本号 — 与 uni-app .version 一致 */}
|
||||
<p className="mt-8 text-center text-xs text-gray-300">生产流转 v1.0.0</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
|
||||
import { useState, useCallback } from "react";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { useState, useCallback, Suspense, lazy } from "react";
|
||||
import { QrCode, Loader2 } from "lucide-react";
|
||||
import { scanProduct } from "../services/productApi";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
import CameraScanner from "../components/scan/CameraScanner";
|
||||
import ManualInput from "../components/scan/ManualInput";
|
||||
import QueryResult from "../components/scan/QueryResult";
|
||||
|
||||
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
|
||||
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
|
||||
|
||||
export default function ScanPage() {
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
@ -18,7 +20,7 @@ export default function ScanPage() {
|
||||
|
||||
/** 通用查询 */
|
||||
const doQuery = useCallback(async (sn: string) => {
|
||||
if (sn.length < 8) { setError("序列号至少需要 8 位"); return; }
|
||||
if (sn.length < 8) { setError("产品身份证至少需要 8 位"); return; }
|
||||
setSerialNumber(sn);
|
||||
setLastScanned(sn);
|
||||
setLoading(true);
|
||||
@ -37,7 +39,7 @@ export default function ScanPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 扫码回调:提取纯序列号 */
|
||||
/** 扫码回调:提取纯产品身份证 */
|
||||
const handleScan = useCallback(
|
||||
(decodedText: string) => {
|
||||
setCameraError(null);
|
||||
@ -53,15 +55,23 @@ export default function ScanPage() {
|
||||
<h2 className="text-lg font-bold text-gray-800">扫码干活</h2>
|
||||
</div>
|
||||
|
||||
{/* 摄像头扫码 */}
|
||||
{/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
|
||||
<div className="mt-3 px-4">
|
||||
<CameraScanner
|
||||
active={cameraActive}
|
||||
onToggle={setCameraActive}
|
||||
onScan={handleScan}
|
||||
error={cameraError}
|
||||
onError={setCameraError}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center rounded-xl border border-gray-200 bg-white py-16 shadow-sm">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CameraScanner
|
||||
active={cameraActive}
|
||||
onToggle={setCameraActive}
|
||||
onScan={handleScan}
|
||||
error={cameraError}
|
||||
onError={setCameraError}
|
||||
/>
|
||||
</Suspense>
|
||||
</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 { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
@ -26,6 +27,35 @@ export default function AdminProductsPage() {
|
||||
const [printCopies, setPrintCopies] = useState(1);
|
||||
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() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -124,7 +154,7 @@ export default function AdminProductsPage() {
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">产品管理</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
查看所有产品序列号并生成二维码用于打印标签
|
||||
查看所有产品身份证并生成二维码用于打印标签
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@ -176,38 +206,74 @@ export default function AdminProductsPage() {
|
||||
)}
|
||||
|
||||
{!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">
|
||||
{products.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"
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[calc(100vh-220px)] overflow-auto rounded-xl"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
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.serial_number}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">
|
||||
{p.order_no ?? "—"}
|
||||
</p>
|
||||
{/* 产品身份证 */}
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">
|
||||
{p.serial_number}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">
|
||||
{p.order_no ?? "—"}
|
||||
</p>
|
||||
|
||||
{/* 打印按钮 */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
打印标签
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* 打印按钮 */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
打印标签
|
||||
</button>
|
||||
</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() {
|
||||
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>
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* 系统唯一 ID(只读) */}
|
||||
{/* 产品身份证 — 系统自动生成 16 位 HEX(不可编辑) */}
|
||||
{/* ================================================================ */}
|
||||
<div>
|
||||
<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>
|
||||
<Input
|
||||
value="提交后自动生成 16 位 HEX"
|
||||
@ -466,7 +466,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 产品序列号(选填) */}
|
||||
{/* 产品序列号 — 用户自定义录入(选填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
产品序列号 <span className="text-xs text-gray-400">(选填)</span>
|
||||
|
||||
Reference in New Issue
Block a user