2026-08-05 14:01:36 +08:00
|
|
|
|
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";
|
2026-08-04 17:10:01 +08:00
|
|
|
|
import api from "../../services/api";
|
2026-08-05 14:01:36 +08:00
|
|
|
|
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 类型
|
|
|
|
|
|
// ============================================================
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onCreated: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
interface SelectedMaterial {
|
|
|
|
|
|
material_id: string;
|
|
|
|
|
|
material_name: string;
|
|
|
|
|
|
spec_model: string;
|
|
|
|
|
|
category: string;
|
|
|
|
|
|
material_type: string;
|
2026-08-04 17:10:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 主组件
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-08-04 17:10:01 +08:00
|
|
|
|
export default function CreateProductDialog({ open, onClose, onCreated }: Props) {
|
2026-08-05 14:01:36 +08:00
|
|
|
|
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("");
|
2026-08-04 17:10:01 +08:00
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// ---- 初始化 ----
|
2026-08-04 17:10:01 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (open) {
|
2026-08-05 14:01:36 +08:00
|
|
|
|
setKeyword("");
|
|
|
|
|
|
setActiveKeys([]);
|
|
|
|
|
|
setSelected(null);
|
|
|
|
|
|
setExternalSerial("");
|
|
|
|
|
|
setOrderNo("");
|
2026-08-04 17:10:01 +08:00
|
|
|
|
setCreatedSn(null);
|
2026-08-05 14:01:36 +08:00
|
|
|
|
groupCache.current.clear();
|
|
|
|
|
|
groupLoadingMap.current.clear();
|
|
|
|
|
|
loadSummary();
|
2026-08-04 17:10:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 数据加载
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
/** 搜索分组摘要 */
|
|
|
|
|
|
const loadSummary = useCallback(async (kw?: string) => {
|
|
|
|
|
|
setSummaryLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-04 17:10:01 +08:00
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
2026-08-05 14:01:36 +08:00
|
|
|
|
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,
|
2026-08-04 17:10:01 +08:00
|
|
|
|
});
|
2026-08-05 14:01:36 +08:00
|
|
|
|
setCreatedSn(data.serial_number);
|
2026-08-04 17:10:01 +08:00
|
|
|
|
onCreated();
|
2026-08-05 14:01:36 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
message.error(err?.response?.data?.detail ?? "创建失败");
|
2026-08-04 17:10:01 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 表格列定义
|
|
|
|
|
|
// ============================================================
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
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>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
</div>
|
2026-08-05 14:01:36 +08:00
|
|
|
|
),
|
|
|
|
|
|
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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
2026-08-06 10:37:08 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 打印标签
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
function printCurrentQRCode() {
|
|
|
|
|
|
if (!createdSn) return;
|
|
|
|
|
|
const qrUrl = `/api/v1/products/qrcode/${createdSn}`;
|
2026-08-06 10:44:22 +08:00
|
|
|
|
const productName = selected?.material_name ?? "";
|
|
|
|
|
|
const spec = selected?.spec_model ?? "";
|
|
|
|
|
|
const orderNo2 = orderNo || "—";
|
|
|
|
|
|
const printWindow = window.open("", "_blank", "width=420,height=480");
|
2026-08-06 10:37:08 +08:00
|
|
|
|
if (!printWindow) return;
|
|
|
|
|
|
printWindow.document.write(`
|
|
|
|
|
|
<html>
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<title>打印标签 - ${createdSn}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
@page { size: auto; margin: 0mm; }
|
2026-08-06 10:44:22 +08:00
|
|
|
|
.print-label-container {
|
|
|
|
|
|
display: flex; flex-direction: column; width: 100%; height: 100%;
|
|
|
|
|
|
box-sizing: border-box; padding: 10px; background: #fff;
|
|
|
|
|
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; color: #000; overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.label-top {
|
|
|
|
|
|
display: flex; flex-direction: row; align-items: flex-start; gap: 12px;
|
|
|
|
|
|
flex: 1; min-height: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.qr-box { flex-shrink: 0; width: 50%; }
|
|
|
|
|
|
.qr-box img { width: 100%; height: auto; display: block; }
|
|
|
|
|
|
.info-box { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
|
|
|
|
.info-item { font-size: 14px; font-weight: 900; line-height: 1.15; word-break: break-all; }
|
|
|
|
|
|
.item-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
|
|
|
|
|
|
.label-bottom {
|
|
|
|
|
|
flex-shrink: 0; margin-top: 10px; font-size: 18px; font-weight: 900;
|
|
|
|
|
|
text-align: center; border-top: 2px solid #000; padding-top: 6px; letter-spacing: 1px;
|
2026-08-06 10:37:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
2026-08-06 10:44:22 +08:00
|
|
|
|
<body style="margin:0;">
|
|
|
|
|
|
<div class="print-label-container">
|
|
|
|
|
|
<div class="label-top">
|
|
|
|
|
|
<div class="qr-box">
|
|
|
|
|
|
<img src="${qrUrl}" onload="window.print(); window.close();" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-box">
|
|
|
|
|
|
<div class="info-item item-name">名:${productName}</div>
|
|
|
|
|
|
<div class="info-item">规:${spec}</div>
|
|
|
|
|
|
<div class="info-item">单:${orderNo2}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="label-bottom">码:${createdSn}</div>
|
|
|
|
|
|
</div>
|
2026-08-06 10:37:08 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
|
|
|
|
|
`);
|
|
|
|
|
|
printWindow.document.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 渲染
|
|
|
|
|
|
// ============================================================
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="创建产品"
|
|
|
|
|
|
open={open}
|
|
|
|
|
|
onCancel={onClose}
|
|
|
|
|
|
width={720}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
destroyOnHidden
|
|
|
|
|
|
>
|
|
|
|
|
|
{createdSn ? (
|
|
|
|
|
|
/* ---- 成功页 ---- */
|
2026-08-06 10:44:22 +08:00
|
|
|
|
<div className="py-4">
|
2026-08-06 10:51:28 +08:00
|
|
|
|
<div className="label-preview-box">
|
|
|
|
|
|
<div className="label-top">
|
|
|
|
|
|
<div className="qr-wrapper">
|
|
|
|
|
|
<img src={`/api/v1/products/qrcode/${createdSn}`} alt="QR" className="qr-img" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-wrapper">
|
|
|
|
|
|
<div className="text-item name-item">名:{selected?.material_name ?? ""}</div>
|
|
|
|
|
|
<div className="text-item">规:{selected?.spec_model ?? ""}</div>
|
|
|
|
|
|
<div className="text-item">单:{orderNo || "—"}</div>
|
2026-08-06 10:44:22 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-06 10:51:28 +08:00
|
|
|
|
<div className="label-bottom">码:{createdSn}</div>
|
2026-08-06 10:44:22 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-2 text-center text-sm text-green-600">产品创建成功!</p>
|
|
|
|
|
|
<Space className="mt-3 flex justify-center">
|
|
|
|
|
|
<Button type="primary" onClick={printCurrentQRCode}>🖨️ 直接打印标签</Button>
|
|
|
|
|
|
<Button onClick={() => setCreatedSn(null)}>继续创建</Button>
|
2026-08-05 14:01:36 +08:00
|
|
|
|
</Space>
|
2026-08-06 10:51:28 +08:00
|
|
|
|
<style>{`
|
|
|
|
|
|
.label-preview-box { width:100%; max-width:380px; margin:0 auto; border:1px solid #e5e7eb; border-radius:8px; padding:16px; background:#fff; display:flex; flex-direction:column; gap:12px; box-sizing:border-box; }
|
|
|
|
|
|
.label-top { display:flex; flex-direction:row; align-items:flex-start; gap:16px; width:100%; }
|
|
|
|
|
|
.qr-wrapper { flex:0 0 120px; width:120px; height:120px; }
|
|
|
|
|
|
.qr-img { width:100%; height:100%; object-fit:contain; display:block; }
|
|
|
|
|
|
.text-wrapper { flex:1; min-width:0; display:flex; flex-direction:column; gap:8px; }
|
|
|
|
|
|
.text-item { font-size:14px; font-weight:bold; line-height:1.3; color:#000; word-break:break-all; }
|
|
|
|
|
|
.name-item { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:3; overflow:hidden; }
|
|
|
|
|
|
.label-bottom { width:100%; text-align:center; font-size:20px; font-weight:900; color:#000; border-top:2px solid #000; padding-top:10px; letter-spacing:2px; }
|
|
|
|
|
|
`}</style>
|
2026-08-05 14:01:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* ---- 表单 ---- */
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-08-04 17:10:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
{/* 已选物料 → 折叠手风琴,展示紧凑标签 */}
|
|
|
|
|
|
{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 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>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
{/* 手风琴 */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
)}
|
2026-08-05 14:01:36 +08:00
|
|
|
|
</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>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
|
2026-08-05 14:01:36 +08:00
|
|
|
|
{/* 产品序列号(选填) */}
|
|
|
|
|
|
<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>
|
2026-08-04 17:10:01 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|