From 59c182a7433facfe97ac0ae579f02bfb383daa71 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 5 Aug 2026 14:01:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20=E2=80=94=20=E4=BB=BB=E5=8A=A1=E5=85=A8?= =?UTF-8?q?=E6=99=AF=E6=A0=91/=E5=88=9B=E5=BB=BA=E4=BA=A7=E5=93=81/?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=89=93=E5=8D=B0/=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 宏观状态栏 --- frontend/src/App.tsx | 54 +- .../src/components/layout/AdminLayout.tsx | 50 +- frontend/src/components/scan/ProductCard.tsx | 39 +- frontend/src/components/scan/QueryResult.tsx | 2 +- frontend/src/components/scan/TaskListCard.tsx | 88 ++- frontend/src/pages/ScanPage.tsx | 12 + .../src/pages/admin/AdminPrintConfigPage.tsx | 162 +++++ .../src/pages/admin/AdminProductsPage.tsx | 164 ++++- .../src/pages/admin/CreateProductDialog.tsx | 568 +++++++++++++----- 9 files changed, 919 insertions(+), 220 deletions(-) create mode 100644 frontend/src/pages/admin/AdminPrintConfigPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 52111f5..f2c98af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { App as AntApp } from "antd"; import { ToastProvider } from "./components/ui/Toast"; +import { AuthProvider } from "./contexts/AuthContext"; import AppLayout from "./components/layout/AppLayout"; import ScanPage from "./pages/ScanPage"; import MyTasksPage from "./pages/MyTasksPage"; @@ -8,33 +10,43 @@ import NotificationsPage from "./pages/NotificationsPage"; import ProfilePage from "./pages/ProfilePage"; import AdminLayout from "./components/layout/AdminLayout"; +import AdminLoginPage from "./pages/admin/AdminLoginPage"; import AdminDashboard from "./pages/admin/AdminDashboard"; import AdminProductsPage from "./pages/admin/AdminProductsPage"; import AdminTasksPage from "./pages/admin/AdminTasksPage"; +import AdminPrintConfigPage from "./pages/admin/AdminPrintConfigPage"; export default function App() { return ( - - - - {/* 移动端 */} - } /> - }> - } /> - } /> - } /> - } /> - + + + + + + {/* 移动端 */} + } /> + }> + } /> + } /> + } /> + } /> + - {/* PC 管理端 */} - } /> - }> - } /> - } /> - } /> - - - - + {/* PC 管理端 — 登录页(独立,无侧边栏) */} + } /> + + {/* PC 管理端 — 需要登录 */} + } /> + }> + } /> + } /> + } /> + } /> + + + + + + ); } diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 478e917..6be1ab3 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,5 +1,6 @@ -import { NavLink, Outlet, useLocation } from "react-router-dom"; -import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch } from "lucide-react"; +import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; +import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User } from "lucide-react"; +import { useAuth } from "../../contexts/AuthContext"; const MENU = [ { @@ -24,6 +25,27 @@ const MENU = [ export default function AdminLayout() { const location = useLocation(); + const navigate = useNavigate(); + const { user, logout, isAuthenticated, loading } = useAuth(); + + // 认证加载中 + if (loading) { + return ( +
+
+
+ ); + } + + // 未登录 → 跳转登录页 + if (!isAuthenticated) { + return ; + } + + function handleLogout() { + logout(); + navigate("/admin/login", { replace: true }); + } return (
@@ -74,7 +96,7 @@ export default function AdminLayout() {
-
+
管理端 @@ -83,6 +105,28 @@ export default function AdminLayout() { {MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
+ + {/* 用户信息 + 登出 */} +
+
+ + + {user?.display_name ?? user?.username ?? "—"} + + {user?.role && ( + + {user.role} + + )} +
+ +
diff --git a/frontend/src/components/scan/ProductCard.tsx b/frontend/src/components/scan/ProductCard.tsx index 1222a88..b0660e0 100644 --- a/frontend/src/components/scan/ProductCard.tsx +++ b/frontend/src/components/scan/ProductCard.tsx @@ -1,20 +1,23 @@ /** 产品信息卡片 */ import { Package } from "lucide-react"; import type { ProductScanResponse } from "../../types/api"; +import { TASK_STATUS } from "../../types/api"; const STATUS_LABELS: Record = { - pending: "待处理", - in_progress: "进行中", - completed: "已完成", - cancelled: "已取消", + [TASK_STATUS.PENDING]: "待接收", + [TASK_STATUS.WIP]: "进行中", + [TASK_STATUS.COMPLETED]: "已完成", + [TASK_STATUS.REJECTED]: "已驳回", + [TASK_STATUS.ARCHIVED]: "已入库", }; function statusColor(status: string): string { switch (status) { - case "pending": return "bg-yellow-100 text-yellow-700"; - case "in_progress": return "bg-blue-100 text-blue-700"; - case "completed": return "bg-green-100 text-green-700"; - default: return "bg-gray-100 text-gray-600"; + case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700"; + case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700"; + case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700"; + case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700"; + default: return "bg-gray-100 text-gray-600"; } } @@ -38,13 +41,23 @@ export default function ProductCard({ product }: ProductCardProps) {

{product.serial_number}

- 订单编号 -

{product.order_no}

+ 物料名称 +

{product.material_name || product.material_id || "—"}

- {product.material_id && ( +
+ 规格型号 +

{product.spec_model || "—"}

+
+
+ 订单编号 +

{product.order_no || "—"}

+
+ {product.current_location_id && (
- 物料 ID -

{product.material_id}

+ 当前位置 +

+ {product.current_location_id === "virtual_warehouse" ? "🏭 仓库" : product.current_location_id} +

)}
diff --git a/frontend/src/components/scan/QueryResult.tsx b/frontend/src/components/scan/QueryResult.tsx index 2df73dc..95a97e6 100644 --- a/frontend/src/components/scan/QueryResult.tsx +++ b/frontend/src/components/scan/QueryResult.tsx @@ -33,7 +33,7 @@ export default function QueryResult({ loading, error, product }: QueryResultProp return (
- +
); } diff --git a/frontend/src/components/scan/TaskListCard.tsx b/frontend/src/components/scan/TaskListCard.tsx index 2e19577..84d5dec 100644 --- a/frontend/src/components/scan/TaskListCard.tsx +++ b/frontend/src/components/scan/TaskListCard.tsx @@ -1,25 +1,71 @@ -/** 任务进度列表卡片 */ -import { ClipboardList, ChevronRight } from "lucide-react"; -import type { TaskSummary } from "../../types/api"; +/** 任务进度列表卡片 — 递归渲染 task_tree */ +import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react"; +import type { TaskResponse, TaskSummary } from "../../types/api"; +import { TASK_STATUS } from "../../types/api"; const STATUS_LABELS: Record = { - pending: "待处理", - in_progress: "进行中", - completed: "已完成", - cancelled: "已取消", + [TASK_STATUS.PENDING]: "待接收", + [TASK_STATUS.WIP]: "进行中", + [TASK_STATUS.COMPLETED]: "已完成", + [TASK_STATUS.REJECTED]: "已驳回", + [TASK_STATUS.ARCHIVED]: "已入库", }; function statusColor(status: string): string { switch (status) { - case "pending": return "bg-yellow-100 text-yellow-700"; - case "in_progress": return "bg-blue-100 text-blue-700"; - case "completed": return "bg-green-100 text-green-700"; - default: return "bg-gray-100 text-gray-600"; + case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700"; + case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700"; + case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700"; + case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700"; + default: return "bg-gray-100 text-gray-600"; } } interface TaskListCardProps { - tasks: TaskSummary[]; + tasks: TaskResponse[]; +} + +interface TaskNodeProps { + task: TaskResponse; + depth: number; +} + +function TaskNode({ task, depth }: TaskNodeProps) { + return ( + <> +
+
+
+ {task.is_rework && ( + + 返工 + + )} + {task.child_tasks && task.child_tasks.length > 1 && ( + + 裂变×{task.child_tasks.length} + + )} +

{task.task_name}

+
+

+ 负责人: {task.assignee_id ?? "未分配"} + {task.reject_reason && 驳回: {task.reject_reason}} +

+
+ + {STATUS_LABELS[task.status] ?? task.status} + +
+ {task.child_tasks?.map((child) => ( + + ))} + + ); +} + +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) { @@ -27,27 +73,15 @@ export default function TaskListCard({ tasks }: TaskListCardProps) {
-

当前进度

- {tasks.length} 个任务 +

任务流转树

+ {countAll(tasks)} 个任务
- {tasks.length === 0 ? (
暂无关联任务
) : (
{tasks.map((task) => ( -
-
-

{task.task_name}

-

- 负责人: {task.assignee_id ?? "未分配"} -

-
- - {STATUS_LABELS[task.status] ?? task.status} - - -
+ ))}
)} diff --git a/frontend/src/pages/ScanPage.tsx b/frontend/src/pages/ScanPage.tsx index 88c982d..0508629 100644 --- a/frontend/src/pages/ScanPage.tsx +++ b/frontend/src/pages/ScanPage.tsx @@ -75,6 +75,18 @@ export default function ScanPage() { />
+ {/* 宏观状态栏 */} + {product && ( +
+
+ 宏观状态 + + {product.overall_status || "未设定"} + +
+
+ )} + {/* 查询结果 */}
diff --git a/frontend/src/pages/admin/AdminPrintConfigPage.tsx b/frontend/src/pages/admin/AdminPrintConfigPage.tsx new file mode 100644 index 0000000..7c362db --- /dev/null +++ b/frontend/src/pages/admin/AdminPrintConfigPage.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+
+

打印机设置

+

+ 配置标签打印机(热敏打标机)的 IP 地址和端口,协议: TSPL +

+
+ +
+
+ {/* IP 地址 */} +
+ + 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" + /> +
+ + {/* 端口 */} +
+ + 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" + /> +

+ 热敏打印机通常使用 9100 端口(Raw Socket) +

+
+ + {/* 启用状态 */} + + + {/* 保存 */} + +
+ + {/* 当前状态 */} + {config?.label_printer && ( +
+

+ 当前配置: {config.label_printer.ip}:{config.label_printer.port} + {" · "} + {config.label_printer.enabled ? ( + 已启用 + ) : ( + 已禁用 + )} +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminProductsPage.tsx b/frontend/src/pages/admin/AdminProductsPage.tsx index ed347e9..ac50e35 100644 --- a/frontend/src/pages/admin/AdminProductsPage.tsx +++ b/frontend/src/pages/admin/AdminProductsPage.tsx @@ -1,17 +1,31 @@ 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 type { ProductResponse } from "../../types/admin"; 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"; export default function AdminProductsPage() { + const { toast } = useToast(); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showCreate, setShowCreate] = useState(false); + // 打印弹窗状态 + const [printTarget, setPrintTarget] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [printLoading, setPrintLoading] = useState(false); + const [printCopies, setPrintCopies] = useState(1); + const [printing, setPrinting] = useState(false); + async function loadProducts() { setLoading(true); setError(null); @@ -29,8 +43,54 @@ export default function AdminProductsPage() { 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 w = window.open("", "_blank", "width=400,height=500"); if (!w) return; @@ -68,6 +128,13 @@ export default function AdminProductsPage() {

+ + +
)} + setShowCreate(false)} onCreated={loadProducts} /> + + {/* ============================================================ */} + {/* 打印预览弹窗 */} + {/* ============================================================ */} + {printTarget && ( +
+
!printing && setPrintTarget(null)} + /> +
+
+

+ 标签打印预览 +

+ +
+ + {/* 预览图 */} +
+ {printLoading || !previewUrl ? ( +
+ +
+ ) : ( + 标签预览 + )} +
+ +

+ {printTarget.serial_number} +

+ + {/* 份数选择 */} +
+ 打印份数 +
+ + + {printCopies} + + +
+
+ + {/* 按钮 */} +
+ + +
+
+
+ )}
); } diff --git a/frontend/src/pages/admin/CreateProductDialog.tsx b/frontend/src/pages/admin/CreateProductDialog.tsx index d717e55..9c08f31 100644 --- a/frontend/src/pages/admin/CreateProductDialog.tsx +++ b/frontend/src/pages/admin/CreateProductDialog.tsx @@ -1,7 +1,12 @@ -import { useState, useEffect } from "react"; -import { X, Loader2, QrCode } from "lucide-react"; +import { useState, useEffect, useRef, useCallback } from "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 { listOrders, type OrderOption } from "../../services/orderApi"; +import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi"; + +// ============================================================ +// 类型 +// ============================================================ interface Props { open: boolean; @@ -9,181 +14,444 @@ interface Props { onCreated: () => void; } -/** 生成 16 位随机 hex 序列号 */ -function genSerial(): string { - const chars = "0123456789ABCDEF"; - let s = ""; - for (let i = 0; i < 16; i++) { - s += chars[Math.floor(Math.random() * 16)]; - } - return s; +interface SelectedMaterial { + material_id: string; + material_name: string; + spec_model: string; + category: string; + material_type: string; } +// ============================================================ +// 主组件 +// ============================================================ + export default function CreateProductDialog({ open, onClose, onCreated }: Props) { - const [orders, setOrders] = useState([]); - const [serialNumber, setSerialNumber] = useState(genSerial()); - const [orderId, setOrderId] = useState(""); - const [materialId, setMaterialId] = useState(""); + const { message } = App.useApp(); + + // ---- 搜索 & 分组摘要 ---- + const [keyword, setKeyword] = useState(""); + const [summary, setSummary] = useState([]); + const [summaryLoading, setSummaryLoading] = useState(false); + + // ---- 手风琴展开 keys ---- + const [activeKeys, setActiveKeys] = useState([]); + + // ---- 缓存 (对标老系统 groupCache / groupLoadingMap) ---- + const groupCache = useRef>(new Map()); + const groupLoadingMap = useRef>(new Map()); + + // ---- 选中物料 ---- + const [selected, setSelected] = useState(null); + + // ---- 表单 ---- + const [externalSerial, setExternalSerial] = useState(""); + const [orderNo, setOrderNo] = useState(""); const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); const [createdSn, setCreatedSn] = useState(null); + // ---- 初始化 ---- useEffect(() => { if (open) { - setSerialNumber(genSerial()); - setError(null); + setKeyword(""); + setActiveKeys([]); + setSelected(null); + setExternalSerial(""); + setOrderNo(""); setCreatedSn(null); - listOrders() - .then(setOrders) - .catch(() => setError("加载订单列表失败")); + groupCache.current.clear(); + groupLoadingMap.current.clear(); + loadSummary(); } }, [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 { - await api.post("/products/", { - serial_number: serialNumber, - order_id: orderId, - material_id: materialId || null, + const list = await fetchMaterialGroups(kw?.trim() || undefined); + setSummary(list); + } catch { + 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(); - } catch (err: unknown) { - const 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) || "创建失败" - ); + } catch (err: any) { + message.error(err?.response?.data?.detail ?? "创建失败"); } finally { setSubmitting(false); } } - if (!open) return null; + // ============================================================ + // 表格列定义 + // ============================================================ + + const columns = [ + { + title: "名称", + dataIndex: "name", + key: "name", + ellipsis: true, + render: (v: string) => {v}, + }, + { + title: "规格", + dataIndex: "spec", + key: "spec", + ellipsis: true, + }, + { + title: "类型", + dataIndex: "type", + key: "type", + width: 80, + render: (v: string) => {v}, + }, + { + title: "单位", + dataIndex: "unit", + key: "unit", + width: 60, + }, + { + title: "操作", + key: "action", + width: 80, + render: (_: unknown, record: MaterialItem) => ( + + ), + }, + ]; + + // ============================================================ + // 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: ( +
+ {group.category} + {group.count} +
+ ), + children: isLoading ? ( +
+ +
+ ) : items && items.length > 0 ? ( + ({ + className: + selected?.material_id === String(record.id) ? "bg-blue-50" : "", + })} + /> + ) : ( + + ), + }; + }); + + // ============================================================ + // 渲染 + // ============================================================ return ( -
-
- {/* 标题 */} -
-

创建产品

- + + {createdSn ? ( + /* ---- 成功页 ---- */ +
+ {`QR-${createdSn}`} +

+ {createdSn} +

+

产品创建成功!

+ + + +
- - {/* 创建成功 — 展示二维码 */} - {createdSn ? ( -
- {`QR-${createdSn}`} -

- {createdSn} -

-

产品创建成功!

-
- - + ) : ( + /* ---- 表单 ---- */ +
+ {/* ================================================================ */} + {/* 物料选择区域 */} + {/* ================================================================ */} +
+
+ + MOM 物料 * + + {!selected && ( + + + + + )}
- -
- ) : ( - /* 表单 */ -
- {/* 序列号 */} -
- -
- setSerialNumber(e.target.value.toUpperCase())} - maxLength={16} - 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序列号" - required - /> - + + {/* 已选物料 → 折叠手风琴,展示紧凑标签 */} + {selected ? ( +
+
+ + {selected.material_name} + +
+ 规格: {selected.spec_model} + 分类: {selected.category} + 类型: {selected.material_type} +
+
+
-
+ ) : ( + <> + {/* 搜索栏 */} +
+ } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={handleSearch} + allowClear + /> + +
- {/* 订单 */} -
- - -
- - {/* 物料 ID */} -
- - 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="关联老系统物料" - /> -
- - {error && ( -
{error}
+ {/* 手风琴 */} +
+ {summaryLoading ? ( +
+ +
+ ) : summary.length === 0 ? ( + + ) : ( + + )} +
+ )} +
- - - )} -
-
+ {/* ================================================================ */} + {/* 系统唯一 ID(只读) */} + {/* ================================================================ */} +
+ + +
+ + {/* 产品序列号(选填) */} +
+ + setExternalSerial(e.target.value)} + maxLength={64} + placeholder="用户自定义序列号" + /> +
+ + {/* 所属订单(选填) */} +
+ + setOrderNo(e.target.value)} + maxLength={64} + placeholder="自由键入订单号" + /> +
+ + {/* 提交 */} + +
+ )} +
); }