feat(frontend): 管理端页面 — 任务全景树/创建产品/标签打印/认证守卫
App.tsx: - AntApp + AuthProvider + ToastProvider 三层包裹 - /admin/login 独立登录页 - /admin/* 路由认证守卫 AdminLayout: - 未登录→跳转登录; 顶部栏显示用户名+登出 - 侧边栏: 全局概览/产品管理/任务全景 CreateProductDialog (Ant Design 重写): - MOM物料手风琴选择器 (Collapse+Table) - 分组懒加载 + useRef缓存 + 防抖搜索 - HEX ID只读展示 + 序列号/订单号选填 AdminProductsPage: - 打印预览弹窗 (Base64标签预览 + 份数选择) - 打印机设置入口 → AdminPrintConfigPage 扫码组件对齐: - ProductCard: material_name/spec_model/current_location - TaskListCard: 递归 task_tree + 状态配色 + 返工/裂变标签 - QueryResult: task_tree 优先 - ScanPage: 宏观状态栏
This commit is contained in:
@ -1,6 +1,8 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { App as AntApp } from "antd";
|
||||||
|
|
||||||
import { ToastProvider } from "./components/ui/Toast";
|
import { ToastProvider } from "./components/ui/Toast";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import AppLayout from "./components/layout/AppLayout";
|
import AppLayout from "./components/layout/AppLayout";
|
||||||
import ScanPage from "./pages/ScanPage";
|
import ScanPage from "./pages/ScanPage";
|
||||||
import MyTasksPage from "./pages/MyTasksPage";
|
import MyTasksPage from "./pages/MyTasksPage";
|
||||||
@ -8,33 +10,43 @@ import NotificationsPage from "./pages/NotificationsPage";
|
|||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
|
||||||
import AdminLayout from "./components/layout/AdminLayout";
|
import AdminLayout from "./components/layout/AdminLayout";
|
||||||
|
import AdminLoginPage from "./pages/admin/AdminLoginPage";
|
||||||
import AdminDashboard from "./pages/admin/AdminDashboard";
|
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||||
import AdminProductsPage from "./pages/admin/AdminProductsPage";
|
import AdminProductsPage from "./pages/admin/AdminProductsPage";
|
||||||
import AdminTasksPage from "./pages/admin/AdminTasksPage";
|
import AdminTasksPage from "./pages/admin/AdminTasksPage";
|
||||||
|
import AdminPrintConfigPage from "./pages/admin/AdminPrintConfigPage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<AntApp>
|
||||||
<BrowserRouter>
|
<ToastProvider>
|
||||||
<Routes>
|
<AuthProvider>
|
||||||
{/* 移动端 */}
|
<BrowserRouter>
|
||||||
<Route path="/" element={<Navigate to="/scan" replace />} />
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
{/* 移动端 */}
|
||||||
<Route path="/scan" element={<ScanPage />} />
|
<Route path="/" element={<Navigate to="/scan" replace />} />
|
||||||
<Route path="/tasks" element={<MyTasksPage />} />
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/scan" element={<ScanPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/tasks" element={<MyTasksPage />} />
|
||||||
</Route>
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* PC 管理端 */}
|
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
|
||||||
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||||
<Route element={<AdminLayout />}>
|
|
||||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
{/* PC 管理端 — 需要登录 */}
|
||||||
<Route path="/admin/products" element={<AdminProductsPage />} />
|
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
<Route element={<AdminLayout />}>
|
||||||
</Route>
|
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||||
</Routes>
|
<Route path="/admin/products" element={<AdminProductsPage />} />
|
||||||
</BrowserRouter>
|
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
||||||
</ToastProvider>
|
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</AntApp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
|
||||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch } from "lucide-react";
|
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User } from "lucide-react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
||||||
const MENU = [
|
const MENU = [
|
||||||
{
|
{
|
||||||
@ -24,6 +25,27 @@ const MENU = [
|
|||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, logout, isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
// 认证加载中
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录 → 跳转登录页
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/admin/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
@ -74,7 +96,7 @@ export default function AdminLayout() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="ml-56 flex-1">
|
<main className="ml-56 flex-1">
|
||||||
<div className="sticky top-0 z-30 border-b border-gray-200 bg-white px-6 py-3">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
<LayoutDashboard className="h-3 w-3" />
|
<LayoutDashboard className="h-3 w-3" />
|
||||||
<span>管理端</span>
|
<span>管理端</span>
|
||||||
@ -83,6 +105,28 @@ export default function AdminLayout() {
|
|||||||
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
|
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 用户信息 + 登出 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<User className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{user?.display_name ?? user?.username ?? "—"}
|
||||||
|
</span>
|
||||||
|
{user?.role && (
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3 w-3" />
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
/** 产品信息卡片 */
|
/** 产品信息卡片 */
|
||||||
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";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
pending: "待处理",
|
[TASK_STATUS.PENDING]: "待接收",
|
||||||
in_progress: "进行中",
|
[TASK_STATUS.WIP]: "进行中",
|
||||||
completed: "已完成",
|
[TASK_STATUS.COMPLETED]: "已完成",
|
||||||
cancelled: "已取消",
|
[TASK_STATUS.REJECTED]: "已驳回",
|
||||||
|
[TASK_STATUS.ARCHIVED]: "已入库",
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
function statusColor(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "pending": return "bg-yellow-100 text-yellow-700";
|
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
|
||||||
case "in_progress": return "bg-blue-100 text-blue-700";
|
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
|
||||||
case "completed": return "bg-green-100 text-green-700";
|
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
|
||||||
default: return "bg-gray-100 text-gray-600";
|
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
|
||||||
|
default: return "bg-gray-100 text-gray-600";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,13 +41,23 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
<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>
|
||||||
<span className="text-gray-400">订单编号</span>
|
<span className="text-gray-400">物料名称</span>
|
||||||
<p className="font-medium text-gray-800">{product.order_no}</p>
|
<p className="font-medium text-gray-800">{product.material_name || product.material_id || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
{product.material_id && (
|
<div>
|
||||||
|
<span className="text-gray-400">规格型号</span>
|
||||||
|
<p className="font-medium text-gray-800">{product.spec_model || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">订单编号</span>
|
||||||
|
<p className="font-medium text-gray-800">{product.order_no || "—"}</p>
|
||||||
|
</div>
|
||||||
|
{product.current_location_id && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-400">物料 ID</span>
|
<span className="text-gray-400">当前位置</span>
|
||||||
<p className="font-medium text-gray-800">{product.material_id}</p>
|
<p className={`font-medium ${product.current_location_id === "virtual_warehouse" ? "text-purple-600" : "text-gray-800"}`}>
|
||||||
|
{product.current_location_id === "virtual_warehouse" ? "🏭 仓库" : product.current_location_id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProductCard product={product} />
|
<ProductCard product={product} />
|
||||||
<TaskListCard tasks={product.top_level_tasks} />
|
<TaskListCard tasks={product.task_tree || product.top_level_tasks} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,71 @@
|
|||||||
/** 任务进度列表卡片 */
|
/** 任务进度列表卡片 — 递归渲染 task_tree */
|
||||||
import { ClipboardList, ChevronRight } from "lucide-react";
|
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
|
||||||
import type { TaskSummary } from "../../types/api";
|
import type { TaskResponse, TaskSummary } from "../../types/api";
|
||||||
|
import { TASK_STATUS } from "../../types/api";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
pending: "待处理",
|
[TASK_STATUS.PENDING]: "待接收",
|
||||||
in_progress: "进行中",
|
[TASK_STATUS.WIP]: "进行中",
|
||||||
completed: "已完成",
|
[TASK_STATUS.COMPLETED]: "已完成",
|
||||||
cancelled: "已取消",
|
[TASK_STATUS.REJECTED]: "已驳回",
|
||||||
|
[TASK_STATUS.ARCHIVED]: "已入库",
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
function statusColor(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "pending": return "bg-yellow-100 text-yellow-700";
|
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
|
||||||
case "in_progress": return "bg-blue-100 text-blue-700";
|
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
|
||||||
case "completed": return "bg-green-100 text-green-700";
|
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
|
||||||
default: return "bg-gray-100 text-gray-600";
|
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
|
||||||
|
default: return "bg-gray-100 text-gray-600";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskListCardProps {
|
interface TaskListCardProps {
|
||||||
tasks: TaskSummary[];
|
tasks: TaskResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskNodeProps {
|
||||||
|
task: TaskResponse;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskNode({ task, depth }: TaskNodeProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-50" style={{ paddingLeft: 16 + depth * 16 }}>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{task.is_rework && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded bg-red-600 px-1 py-0.5 text-[10px] font-bold text-white">
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5" />返工
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.child_tasks && task.child_tasks.length > 1 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded bg-purple-100 px-1 py-0.5 text-[10px] font-medium text-purple-700">
|
||||||
|
<GitBranch className="h-2.5 w-2.5" />裂变×{task.child_tasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="truncate text-sm font-medium text-gray-800">{task.task_name}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
负责人: {task.assignee_id ?? "未分配"}
|
||||||
|
{task.reject_reason && <span className="ml-2 text-red-500">驳回: {task.reject_reason}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
|
||||||
|
{STATUS_LABELS[task.status] ?? task.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.child_tasks?.map((child) => (
|
||||||
|
<TaskNode key={child.id} task={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countAll(tasks: TaskResponse[]): number {
|
||||||
|
return tasks.reduce((s, t) => s + 1 + (t.child_tasks ? countAll(t.child_tasks) : 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskListCard({ tasks }: TaskListCardProps) {
|
export default function TaskListCard({ tasks }: TaskListCardProps) {
|
||||||
@ -27,27 +73,15 @@ export default function TaskListCard({ tasks }: TaskListCardProps) {
|
|||||||
<div className="rounded-xl bg-white shadow-sm">
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
<div className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
<div className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
<ClipboardList 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 text-xs text-gray-400">{tasks.length} 个任务</span>
|
<span className="ml-auto text-xs text-gray-400">{countAll(tasks)} 个任务</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-sm text-gray-400">暂无关联任务</div>
|
<div className="px-4 py-8 text-center text-sm text-gray-400">暂无关联任务</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<div key={task.id} className="flex items-center gap-3 px-4 py-3">
|
<TaskNode key={task.id} task={task} depth={0} />
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-gray-800">{task.task_name}</p>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
负责人: {task.assignee_id ?? "未分配"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
|
|
||||||
{STATUS_LABELS[task.status] ?? task.status}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-gray-300" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -75,6 +75,18 @@ export default function ScanPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 宏观状态栏 */}
|
||||||
|
{product && (
|
||||||
|
<div className="mt-3 px-4">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-white px-4 py-2.5 shadow-sm">
|
||||||
|
<span className="text-xs text-gray-400">宏观状态</span>
|
||||||
|
<span className={`flex-1 text-sm font-bold ${product.overall_status ? "text-blue-600" : "text-red-500"}`}>
|
||||||
|
{product.overall_status || "未设定"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 查询结果 */}
|
{/* 查询结果 */}
|
||||||
<div className="mt-3 px-4">
|
<div className="mt-3 px-4">
|
||||||
<QueryResult loading={loading} error={error} product={product} />
|
<QueryResult loading={loading} error={error} product={product} />
|
||||||
|
|||||||
162
frontend/src/pages/admin/AdminPrintConfigPage.tsx
Normal file
162
frontend/src/pages/admin/AdminPrintConfigPage.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Settings, Save, Loader2, Wifi, WifiOff } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getPrinterConfig,
|
||||||
|
updatePrinterConfig,
|
||||||
|
type PrinterConfig,
|
||||||
|
} from "../../services/printApi";
|
||||||
|
import { useToast } from "../../components/ui/Toast";
|
||||||
|
|
||||||
|
export default function AdminPrintConfigPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<PrinterConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 表单字段
|
||||||
|
const [ip, setIp] = useState("");
|
||||||
|
const [port, setPort] = useState(9100);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const cfg = await getPrinterConfig();
|
||||||
|
setConfig(cfg);
|
||||||
|
const lp = cfg.label_printer;
|
||||||
|
setIp(lp.ip);
|
||||||
|
setPort(lp.port);
|
||||||
|
setEnabled(lp.enabled ?? true);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? "加载配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!ip.trim()) {
|
||||||
|
toast("请输入打印机 IP 地址", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updatePrinterConfig(ip.trim(), port, enabled);
|
||||||
|
toast(result.message, "success");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? "保存失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">打印机设置</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
配置标签打印机(热敏打标机)的 IP 地址和端口,协议: TSPL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md rounded-xl bg-white p-6 shadow-sm">
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
{/* IP 地址 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
打印机 IP 地址
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ip}
|
||||||
|
onChange={(e) => setIp(e.target.value)}
|
||||||
|
placeholder="如 192.168.9.221"
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 端口 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
端口
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-400">
|
||||||
|
热敏打印机通常使用 9100 端口(Raw Socket)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 启用状态 */}
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{enabled ? (
|
||||||
|
<Wifi className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{enabled ? "已启用" : "已禁用"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 保存 */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
保存设置
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 当前状态 */}
|
||||||
|
{config?.label_printer && (
|
||||||
|
<div className="mt-4 rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500">
|
||||||
|
<p>
|
||||||
|
当前配置: {config.label_printer.ip}:{config.label_printer.port}
|
||||||
|
{" · "}
|
||||||
|
{config.label_printer.enabled ? (
|
||||||
|
<span className="text-green-600 font-medium">已启用</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">已禁用</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,31 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Printer, RefreshCw, Loader2, QrCode, Plus } from "lucide-react";
|
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
|
||||||
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";
|
||||||
|
import {
|
||||||
|
getLabelPreview,
|
||||||
|
executePrint,
|
||||||
|
type LabelPreviewRequest,
|
||||||
|
} from "../../services/printApi";
|
||||||
|
import { useToast } from "../../components/ui/Toast";
|
||||||
|
|
||||||
const QR_BASE = "/api/v1/products/qrcode";
|
const QR_BASE = "/api/v1/products/qrcode";
|
||||||
|
|
||||||
export default function AdminProductsPage() {
|
export default function AdminProductsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
// 打印弹窗状态
|
||||||
|
const [printTarget, setPrintTarget] = useState<ProductResponse | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [printLoading, setPrintLoading] = useState(false);
|
||||||
|
const [printCopies, setPrintCopies] = useState(1);
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
|
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -29,8 +43,54 @@ export default function AdminProductsPage() {
|
|||||||
loadProducts();
|
loadProducts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** 打印单个二维码 */
|
// ---- 打印标签 ----
|
||||||
function handlePrint(serialNumber: string) {
|
|
||||||
|
async function handleOpenPrint(product: ProductResponse) {
|
||||||
|
setPrintTarget(product);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setPrintCopies(1);
|
||||||
|
setPrintLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: LabelPreviewRequest = {
|
||||||
|
serial_number: product.serial_number,
|
||||||
|
material_name: product.material_name ?? product.material_id ?? "",
|
||||||
|
spec_model: product.spec_model ?? "",
|
||||||
|
order_no: product.order_no ?? "",
|
||||||
|
};
|
||||||
|
const url = await getLabelPreview(payload);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? err?.message ?? "生成预览失败", "error");
|
||||||
|
setPrintTarget(null);
|
||||||
|
} finally {
|
||||||
|
setPrintLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmPrint() {
|
||||||
|
if (!printTarget) return;
|
||||||
|
setPrinting(true);
|
||||||
|
try {
|
||||||
|
const result = await executePrint({
|
||||||
|
serial_number: printTarget.serial_number,
|
||||||
|
material_name: printTarget.material_name ?? printTarget.material_id ?? "",
|
||||||
|
spec_model: printTarget.spec_model ?? "",
|
||||||
|
order_no: printTarget.order_no ?? "",
|
||||||
|
copies: printCopies,
|
||||||
|
});
|
||||||
|
toast(result.message, "success");
|
||||||
|
setPrintTarget(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err?.response?.data?.detail ?? err?.message ?? "打印失败", "error");
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 打印二维码(兼容旧功能) ----
|
||||||
|
|
||||||
|
function handlePrintQr(serialNumber: string) {
|
||||||
const qrUrl = `${QR_BASE}/${serialNumber}`;
|
const qrUrl = `${QR_BASE}/${serialNumber}`;
|
||||||
const w = window.open("", "_blank", "width=400,height=500");
|
const w = window.open("", "_blank", "width=400,height=500");
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
@ -68,6 +128,13 @@ export default function AdminProductsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<a
|
||||||
|
href="/admin/print-config"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-50"
|
||||||
|
title="打印机设置"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||||
@ -133,8 +200,8 @@ export default function AdminProductsPage() {
|
|||||||
|
|
||||||
{/* 打印按钮 */}
|
{/* 打印按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePrint(p.serial_number)}
|
onClick={() => handleOpenPrint(p)}
|
||||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-gray-200 py-2 text-xs font-medium text-gray-600 transition-colors hover:bg-blue-50 hover:text-blue-700 hover:border-blue-200"
|
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" />
|
||||||
打印标签
|
打印标签
|
||||||
@ -143,11 +210,98 @@ export default function AdminProductsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateProductDialog
|
<CreateProductDialog
|
||||||
open={showCreate}
|
open={showCreate}
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
onCreated={loadProducts}
|
onCreated={loadProducts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* 打印预览弹窗 */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{printTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={() => !printing && setPrintTarget(null)}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-bold text-gray-800">
|
||||||
|
标签打印预览
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setPrintTarget(null)}
|
||||||
|
disabled={printing}
|
||||||
|
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览图 */}
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
{printLoading || !previewUrl ? (
|
||||||
|
<div className="flex items-center justify-center rounded-lg bg-gray-50 w-full h-48">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="标签预览"
|
||||||
|
className="max-h-64 rounded-lg border border-gray-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">
|
||||||
|
{printTarget.serial_number}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 份数选择 */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">打印份数</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPrintCopies((c) => Math.max(1, c - 1))}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-8 text-center text-sm font-semibold">
|
||||||
|
{printCopies}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPrintCopies((c) => Math.min(100, c + 1))}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPrintTarget(null)}
|
||||||
|
disabled={printing}
|
||||||
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmPrint}
|
||||||
|
disabled={printing || printLoading}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
确认打印
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { X, Loader2, QrCode } from "lucide-react";
|
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
|
||||||
|
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import { listOrders, type OrderOption } from "../../services/orderApi";
|
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -9,181 +14,444 @@ interface Props {
|
|||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成 16 位随机 hex 序列号 */
|
interface SelectedMaterial {
|
||||||
function genSerial(): string {
|
material_id: string;
|
||||||
const chars = "0123456789ABCDEF";
|
material_name: string;
|
||||||
let s = "";
|
spec_model: string;
|
||||||
for (let i = 0; i < 16; i++) {
|
category: string;
|
||||||
s += chars[Math.floor(Math.random() * 16)];
|
material_type: string;
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主组件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export default function CreateProductDialog({ open, onClose, onCreated }: Props) {
|
export default function CreateProductDialog({ open, onClose, onCreated }: Props) {
|
||||||
const [orders, setOrders] = useState<OrderOption[]>([]);
|
const { message } = App.useApp();
|
||||||
const [serialNumber, setSerialNumber] = useState(genSerial());
|
|
||||||
const [orderId, setOrderId] = useState("");
|
// ---- 搜索 & 分组摘要 ----
|
||||||
const [materialId, setMaterialId] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [summary, setSummary] = useState<MaterialGroup[]>([]);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
|
|
||||||
|
// ---- 手风琴展开 keys ----
|
||||||
|
const [activeKeys, setActiveKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// ---- 缓存 (对标老系统 groupCache / groupLoadingMap) ----
|
||||||
|
const groupCache = useRef<Map<string, MaterialItem[]>>(new Map());
|
||||||
|
const groupLoadingMap = useRef<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
|
// ---- 选中物料 ----
|
||||||
|
const [selected, setSelected] = useState<SelectedMaterial | null>(null);
|
||||||
|
|
||||||
|
// ---- 表单 ----
|
||||||
|
const [externalSerial, setExternalSerial] = useState("");
|
||||||
|
const [orderNo, setOrderNo] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ---- 初始化 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSerialNumber(genSerial());
|
setKeyword("");
|
||||||
setError(null);
|
setActiveKeys([]);
|
||||||
|
setSelected(null);
|
||||||
|
setExternalSerial("");
|
||||||
|
setOrderNo("");
|
||||||
setCreatedSn(null);
|
setCreatedSn(null);
|
||||||
listOrders()
|
groupCache.current.clear();
|
||||||
.then(setOrders)
|
groupLoadingMap.current.clear();
|
||||||
.catch(() => setError("加载订单列表失败"));
|
loadSummary();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
// ============================================================
|
||||||
e.preventDefault();
|
// 数据加载
|
||||||
if (!orderId) { setError("请选择订单"); return; }
|
// ============================================================
|
||||||
if (serialNumber.length !== 16) { setError("序列号必须为16位"); return; }
|
|
||||||
|
|
||||||
setSubmitting(true);
|
/** 搜索分组摘要 */
|
||||||
setError(null);
|
const loadSummary = useCallback(async (kw?: string) => {
|
||||||
|
setSummaryLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/products/", {
|
const list = await fetchMaterialGroups(kw?.trim() || undefined);
|
||||||
serial_number: serialNumber,
|
setSummary(list);
|
||||||
order_id: orderId,
|
} catch {
|
||||||
material_id: materialId || null,
|
message.error("加载物料分组失败");
|
||||||
|
} finally {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
setActiveKeys([]);
|
||||||
|
groupCache.current.clear();
|
||||||
|
groupLoadingMap.current.clear();
|
||||||
|
loadSummary(keyword.trim() || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖搜索:输入即搜,300ms 无键入后自动触发
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleSearch();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
|
/** 懒加载分组内物料 */
|
||||||
|
async function loadGroupItems(category: string) {
|
||||||
|
// 缓存命中 → 跳过
|
||||||
|
if (groupCache.current.has(category)) return;
|
||||||
|
// 正在加载 → 跳过
|
||||||
|
if (groupLoadingMap.current.get(category)) return;
|
||||||
|
|
||||||
|
groupLoadingMap.current.set(category, true);
|
||||||
|
// 触发重渲染让 Table 显示 loading
|
||||||
|
forceRefresh();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await fetchMaterialItems(category, keyword.trim() || undefined);
|
||||||
|
groupCache.current.set(category, items);
|
||||||
|
} catch {
|
||||||
|
message.error(`加载 "${category}" 分组失败`);
|
||||||
|
} finally {
|
||||||
|
groupLoadingMap.current.set(category, false);
|
||||||
|
forceRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制刷新 — 因为 useRef 不会触发重渲染,借用 state 刷新 */
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
function forceRefresh() {
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 手风琴事件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function handleCollapseChange(keys: string | string[]) {
|
||||||
|
const newKeys = Array.isArray(keys) ? keys : [keys];
|
||||||
|
setActiveKeys(newKeys);
|
||||||
|
|
||||||
|
// 新展开的 panel → 懒加载
|
||||||
|
const newlyOpened = newKeys.filter((k) => !activeKeys.includes(k));
|
||||||
|
newlyOpened.forEach((cat) => loadGroupItems(cat));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAll() {
|
||||||
|
const all = summary.map((g) => g.category);
|
||||||
|
setActiveKeys(all);
|
||||||
|
all.forEach((cat) => loadGroupItems(cat));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
setActiveKeys([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 选择物料
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function handleSelect(item: MaterialItem) {
|
||||||
|
setSelected({
|
||||||
|
material_id: String(item.id),
|
||||||
|
material_name: item.name,
|
||||||
|
spec_model: item.spec,
|
||||||
|
category: item.category,
|
||||||
|
material_type: item.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 提交创建
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!selected) {
|
||||||
|
message.warning("请选择一个物料");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post("/products/", {
|
||||||
|
material_id: selected.material_id,
|
||||||
|
material_name: selected.material_name,
|
||||||
|
spec_model: selected.spec_model,
|
||||||
|
category: selected.category,
|
||||||
|
material_type: selected.material_type,
|
||||||
|
external_serial: externalSerial.trim() || null,
|
||||||
|
order_no: orderNo.trim() || null,
|
||||||
});
|
});
|
||||||
setCreatedSn(serialNumber);
|
setCreatedSn(data.serial_number);
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err: unknown) {
|
} catch (err: any) {
|
||||||
const detail =
|
message.error(err?.response?.data?.detail ?? "创建失败");
|
||||||
err && typeof err === "object" && "response" in err
|
|
||||||
? (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
|
|
||||||
: null;
|
|
||||||
setError(
|
|
||||||
typeof detail === "string"
|
|
||||||
? detail
|
|
||||||
: JSON.stringify(detail) || "创建失败"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
// ============================================================
|
||||||
|
// 表格列定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "名称",
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => <span className="font-medium text-gray-800">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "规格",
|
||||||
|
dataIndex: "spec",
|
||||||
|
key: "spec",
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "类型",
|
||||||
|
dataIndex: "type",
|
||||||
|
key: "type",
|
||||||
|
width: 80,
|
||||||
|
render: (v: string) => <Tag>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "单位",
|
||||||
|
dataIndex: "unit",
|
||||||
|
key: "unit",
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, record: MaterialItem) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSelect(record);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Collapse items 生成
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const collapseItems = summary.map((group) => {
|
||||||
|
const items = groupCache.current.get(group.category);
|
||||||
|
const isLoading = groupLoadingMap.current.get(group.category) === true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: group.category,
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center justify-between pr-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">{group.category}</span>
|
||||||
|
<Tag className="ml-2">{group.count}</Tag>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
children: isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : items && items.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 240 }}
|
||||||
|
onRow={(record) => ({
|
||||||
|
className:
|
||||||
|
selected?.material_id === String(record.id) ? "bg-blue-50" : "",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="该分类下暂无物料"
|
||||||
|
className="py-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 渲染
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
<Modal
|
||||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
title="创建产品"
|
||||||
{/* 标题 */}
|
open={open}
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
onCancel={onClose}
|
||||||
<h3 className="text-lg font-semibold text-gray-800">创建产品</h3>
|
width={720}
|
||||||
<button onClick={onClose} className="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
footer={null}
|
||||||
<X className="h-5 w-5" />
|
destroyOnHidden
|
||||||
</button>
|
>
|
||||||
|
{createdSn ? (
|
||||||
|
/* ---- 成功页 ---- */
|
||||||
|
<div className="flex flex-col items-center py-6">
|
||||||
|
<img
|
||||||
|
src={`/api/v1/products/qrcode/${createdSn}`}
|
||||||
|
alt={`QR-${createdSn}`}
|
||||||
|
className="h-48 w-48 rounded-lg border"
|
||||||
|
/>
|
||||||
|
<p className="mt-3 font-mono text-lg font-bold tracking-widest text-gray-800">
|
||||||
|
{createdSn}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
|
||||||
|
<Space className="mt-5">
|
||||||
|
<Button onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}>
|
||||||
|
打开二维码
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => setCreatedSn(null)}>
|
||||||
|
继续创建
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{/* 创建成功 — 展示二维码 */}
|
/* ---- 表单 ---- */
|
||||||
{createdSn ? (
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col items-center px-6 py-8">
|
{/* ================================================================ */}
|
||||||
<img
|
{/* 物料选择区域 */}
|
||||||
src={`/api/v1/products/qrcode/${createdSn}`}
|
{/* ================================================================ */}
|
||||||
alt={`QR-${createdSn}`}
|
<div>
|
||||||
className="h-52 w-52 rounded-lg border"
|
<div className="mb-2 flex items-center justify-between">
|
||||||
/>
|
<span className="text-sm font-medium text-gray-700">
|
||||||
<p className="mt-4 font-mono text-lg font-bold tracking-wider text-gray-800">
|
MOM 物料 <span className="text-red-500">*</span>
|
||||||
{createdSn}
|
</span>
|
||||||
</p>
|
{!selected && (
|
||||||
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
|
<Space size="small">
|
||||||
<div className="mt-6 flex w-full gap-3">
|
<Button size="small" icon={<PlusOutlined />} onClick={expandAll}>
|
||||||
<button
|
全部展开
|
||||||
onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}
|
</Button>
|
||||||
className="flex-1 rounded-lg border border-gray-200 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
<Button size="small" icon={<MinusOutlined />} onClick={collapseAll}>
|
||||||
>
|
全部折叠
|
||||||
打开二维码
|
</Button>
|
||||||
</button>
|
</Space>
|
||||||
<button
|
)}
|
||||||
onClick={() => {
|
|
||||||
setCreatedSn(null);
|
|
||||||
setSerialNumber(genSerial());
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
继续创建
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="mt-3 w-full rounded-lg py-2 text-sm text-gray-400 hover:text-gray-600">
|
|
||||||
关闭
|
{/* 已选物料 → 折叠手风琴,展示紧凑标签 */}
|
||||||
</button>
|
{selected ? (
|
||||||
</div>
|
<div className="flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||||
) : (
|
<div>
|
||||||
/* 表单 */
|
<span className="text-base font-semibold text-blue-800">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
{selected.material_name}
|
||||||
{/* 序列号 */}
|
</span>
|
||||||
<div>
|
<div className="mt-0.5 flex gap-3 text-xs text-blue-500">
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">产品序列号 (16位)</label>
|
<span>规格: {selected.spec_model}</span>
|
||||||
<div className="flex gap-2">
|
<span>分类: {selected.category}</span>
|
||||||
<input
|
<span>类型: {selected.material_type}</span>
|
||||||
value={serialNumber}
|
</div>
|
||||||
onChange={(e) => setSerialNumber(e.target.value.toUpperCase())}
|
</div>
|
||||||
maxLength={16}
|
<Button danger size="small" onClick={() => setSelected(null)}>
|
||||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:outline-none"
|
清除重选
|
||||||
placeholder="16位HEX序列号"
|
</Button>
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSerialNumber(genSerial())}
|
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
随机
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="mb-2 flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索名称/规格…"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch}>搜索</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 订单 */}
|
{/* 手风琴 */}
|
||||||
<div>
|
<div className="max-h-[360px] overflow-y-auto rounded-lg border border-gray-200">
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">所属订单</label>
|
{summaryLoading ? (
|
||||||
<select
|
<div className="flex items-center justify-center py-16">
|
||||||
value={orderId}
|
<Spin />
|
||||||
onChange={(e) => setOrderId(e.target.value)}
|
</div>
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
) : summary.length === 0 ? (
|
||||||
required
|
<Empty
|
||||||
>
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
<option value="">请选择订单</option>
|
description="暂无成品/半成品数据"
|
||||||
{orders.map((o) => (
|
className="py-10"
|
||||||
<option key={o.id} value={o.id}>{o.order_no}</option>
|
/>
|
||||||
))}
|
) : (
|
||||||
</select>
|
<Collapse
|
||||||
</div>
|
activeKey={activeKeys}
|
||||||
|
onChange={handleCollapseChange}
|
||||||
{/* 物料 ID */}
|
size="small"
|
||||||
<div>
|
ghost
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
items={collapseItems}
|
||||||
物料 ID <span className="text-gray-400">(可选)</span>
|
/>
|
||||||
</label>
|
)}
|
||||||
<input
|
</div>
|
||||||
value={materialId}
|
</>
|
||||||
onChange={(e) => setMaterialId(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
|
||||||
placeholder="关联老系统物料"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{/* ================================================================ */}
|
||||||
type="submit"
|
{/* 系统唯一 ID(只读) */}
|
||||||
disabled={submitting}
|
{/* ================================================================ */}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
<div>
|
||||||
>
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <QrCode className="h-4 w-4" />}
|
系统唯一 ID <span className="text-xs text-gray-400">(自动生成)</span>
|
||||||
创建并生成二维码
|
</label>
|
||||||
</button>
|
<Input
|
||||||
</form>
|
value="提交后自动生成 16 位 HEX"
|
||||||
)}
|
disabled
|
||||||
</div>
|
className="font-mono text-gray-400"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 产品序列号(选填) */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
产品序列号 <span className="text-xs text-gray-400">(选填)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={externalSerial}
|
||||||
|
onChange={(e) => setExternalSerial(e.target.value)}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="用户自定义序列号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 所属订单(选填) */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
所属订单 <span className="text-xs text-gray-400">(选填)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={orderNo}
|
||||||
|
onChange={(e) => setOrderNo(e.target.value)}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="自由键入订单号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提交 */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!selected}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
创建并生成二维码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user