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

511 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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