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:
2026-08-05 14:01:36 +08:00
parent d04085fc38
commit 59c182a743
9 changed files with 919 additions and 220 deletions

View File

@ -75,6 +75,18 @@ export default function ScanPage() {
/>
</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">
<QueryResult loading={loading} error={error} product={product} />

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

View File

@ -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<ProductResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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() {
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() {
</p>
</div>
<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
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"
@ -133,8 +200,8 @@ export default function AdminProductsPage() {
{/* 打印按钮 */}
<button
onClick={() => handlePrint(p.serial_number)}
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"
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" />
打印标签
@ -143,11 +210,98 @@ export default function AdminProductsPage() {
))}
</div>
)}
<CreateProductDialog
open={showCreate}
onClose={() => setShowCreate(false)}
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>
);
}

View File

@ -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<OrderOption[]>([]);
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<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 [error, setError] = useState<string | null>(null);
const [createdSn, setCreatedSn] = useState<string | null>(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) => <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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
{/* 标题 */}
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<h3 className="text-lg font-semibold text-gray-800">创建产品</h3>
<button onClick={onClose} className="rounded-md p-1 text-gray-400 hover:bg-gray-100">
<X className="h-5 w-5" />
</button>
<Modal
title="创建产品"
open={open}
onCancel={onClose}
width={720}
footer={null}
destroyOnHidden
>
{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>
{/* 创建成功 — 展示二维码 */}
{createdSn ? (
<div className="flex flex-col items-center px-6 py-8">
<img
src={`/api/v1/products/qrcode/${createdSn}`}
alt={`QR-${createdSn}`}
className="h-52 w-52 rounded-lg border"
/>
<p className="mt-4 font-mono text-lg font-bold tracking-wider text-gray-800">
{createdSn}
</p>
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
<div className="mt-6 flex w-full gap-3">
<button
onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}
className="flex-1 rounded-lg border border-gray-200 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
打开二维码
</button>
<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 className="space-y-4">
{/* ================================================================ */}
{/* 物料选择区域 */}
{/* ================================================================ */}
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
MOM 物料 <span className="text-red-500">*</span>
</span>
{!selected && (
<Space size="small">
<Button size="small" icon={<PlusOutlined />} onClick={expandAll}>
全部展开
</Button>
<Button size="small" icon={<MinusOutlined />} onClick={collapseAll}>
全部折叠
</Button>
</Space>
)}
</div>
<button onClick={onClose} className="mt-3 w-full rounded-lg py-2 text-sm text-gray-400 hover:text-gray-600">
关闭
</button>
</div>
) : (
/* 表单 */
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
{/* 序列号 */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">产品序列号 (16位)</label>
<div className="flex gap-2">
<input
value={serialNumber}
onChange={(e) => 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
/>
<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>
{/* 已选物料 → 折叠手风琴,展示紧凑标签 */}
{selected ? (
<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">
{selected.material_name}
</span>
<div className="mt-0.5 flex gap-3 text-xs text-blue-500">
<span>规格: {selected.spec_model}</span>
<span>分类: {selected.category}</span>
<span>类型: {selected.material_type}</span>
</div>
</div>
<Button danger size="small" onClick={() => setSelected(null)}>
清除重选
</Button>
</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>
<label className="mb-1 block text-sm font-medium text-gray-700">所属订单</label>
<select
value={orderId}
onChange={(e) => setOrderId(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"
required
>
<option value="">请选择订单</option>
{orders.map((o) => (
<option key={o.id} value={o.id}>{o.order_no}</option>
))}
</select>
</div>
{/* 物料 ID */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
物料 ID <span className="text-gray-400">(可选)</span>
</label>
<input
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 className="max-h-[360px] overflow-y-auto rounded-lg border border-gray-200">
{summaryLoading ? (
<div className="flex items-center justify-center py-16">
<Spin />
</div>
) : summary.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无成品/半成品数据"
className="py-10"
/>
) : (
<Collapse
activeKey={activeKeys}
onChange={handleCollapseChange}
size="small"
ghost
items={collapseItems}
/>
)}
</div>
</>
)}
</div>
<button
type="submit"
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"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <QrCode className="h-4 w-4" />}
创建并生成二维码
</button>
</form>
)}
</div>
</div>
{/* ================================================================ */}
{/* 系统唯一 ID(只读) */}
{/* ================================================================ */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
系统唯一 ID <span className="text-xs text-gray-400">(自动生成)</span>
</label>
<Input
value="提交后自动生成 16 位 HEX"
disabled
className="font-mono text-gray-400"
/>
</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>
);
}