Files
track/frontend/src/pages/admin/CreateProductDialog.tsx

511 lines
17 KiB
TypeScript
Raw Normal View History

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 { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
// ============================================================
// 类型
// ============================================================
interface Props {
open: boolean;
onClose: () => void;
onCreated: () => void;
}
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 { 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 [createdSn, setCreatedSn] = useState<string | null>(null);
// ---- 初始化 ----
useEffect(() => {
if (open) {
setKeyword("");
setActiveKeys([]);
setSelected(null);
setExternalSerial("");
setOrderNo("");
setCreatedSn(null);
groupCache.current.clear();
groupLoadingMap.current.clear();
loadSummary();
}
}, [open]);
// ============================================================
// 数据加载
// ============================================================
/** 搜索分组摘要 */
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));
}
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(data.serial_number);
onCreated();
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "创建失败");
} finally {
setSubmitting(false);
}
}
// ============================================================
// 表格列定义
// ============================================================
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"
/>
),
};
});
// ============================================================
// 打印标签
// ============================================================
function printCurrentQRCode() {
const printArea = document.getElementById("label-print-area");
if (!printArea) return;
const printContent = printArea.innerHTML;
const styles = Array.from(document.querySelectorAll("style, link[rel=\"stylesheet\"]"))
.map(el => el.outerHTML)
.join("");
const iframe = document.createElement("iframe");
iframe.style.position = "absolute";
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.border = "none";
document.body.appendChild(iframe);
const doc = iframe.contentWindow!.document;
doc.write(`
<!DOCTYPE html>
<html>
<head><title>打印标签</title>${styles}
<style>
@page { size: auto; margin: 0mm; }
body { margin: 0; padding: 0; background: #fff; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
</style>
</head>
<body><div style="width:100%;max-width:360px;margin:0 auto;padding:12px;box-sizing:border-box;background:#fff;font-family:monospace,'Helvetica Neue',sans-serif;">${printContent}</div></body>
</html>
`);
doc.close();
iframe.contentWindow!.onload = () => {
iframe.contentWindow!.focus();
iframe.contentWindow!.print();
setTimeout(() => { document.body.removeChild(iframe); }, 1000);
};
}
// ============================================================
// 渲染
// ============================================================
return (
<Modal
title="创建产品"
open={open}
onCancel={onClose}
width={720}
footer={null}
destroyOnHidden
>
{createdSn ? (
/* ---- 成功页 ---- */
<div className="py-4">
<div id="label-print-area" style={{
width: '100%', maxWidth: '360px', margin: '0 auto', padding: '12px',
boxSizing: 'border-box', background: '#fff',
fontFamily: 'monospace, "Helvetica Neue", Helvetica, sans-serif',
border: '1px solid #e5e7eb'
}}>
<div style={{ width: '100%' }}>
<div style={{ float: 'left', width: '110px', height: '110px', marginRight: '10px', marginBottom: '4px' }}>
<img src={`/api/v1/products/qrcode/${createdSn}`} alt="QR"
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
</div>
<div style={{ fontSize: '13px', fontWeight: 900, color: '#000', lineHeight: '1.4', wordBreak: 'break-all' }}>
<div style={{ marginBottom: '4px' }}>名: {selected?.material_name ?? ""}</div>
<div style={{ marginBottom: '4px' }}>规: {selected?.spec_model ?? ""}</div>
<div style={{ marginBottom: '4px' }}>单: {orderNo || "—"}</div>
</div>
<div style={{ clear: 'both' }}></div>
</div>
<div style={{ width: '100%', marginTop: '8px', paddingTop: '8px', borderTop: '2px solid #000',
textAlign: 'center', fontSize: '16px', fontWeight: 900, color: '#000', letterSpacing: '1px' }}>
码: {createdSn}
</div>
</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>
</Space>
</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>
)}
</div>
{/* 已选物料 → 折叠手风琴,展示紧凑标签 */}
{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>
{/* 手风琴 */}
<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>
{/* ================================================================ */}
{/* 产品身份证 — 系统自动生成 16 位 HEX(不可编辑) */}
{/* ================================================================ */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
产品身份证 <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>
);
}