446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Card,
|
|
Form,
|
|
Image,
|
|
Input,
|
|
Modal,
|
|
Pagination,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Upload,
|
|
message,
|
|
} from 'antd';
|
|
import {
|
|
DeleteOutlined,
|
|
EyeOutlined,
|
|
LinkOutlined,
|
|
PlusOutlined,
|
|
QrcodeOutlined,
|
|
StopOutlined,
|
|
UploadOutlined,
|
|
} from '@ant-design/icons';
|
|
import type { UploadFile } from 'antd';
|
|
import { productTracesApi } from '@/services/api';
|
|
import type { CreateProductTraceRequest, ProductTrace, UpdateProductTraceRequest } from '@/types';
|
|
|
|
type ProductTraceFormValues = Omit<CreateProductTraceRequest, 'manufactureDate'> & {
|
|
manufactureDate: string;
|
|
};
|
|
|
|
function formatDate(value: string) {
|
|
if (!value) return '-';
|
|
return new Date(value).toLocaleDateString('zh-CN');
|
|
}
|
|
|
|
function toDateInputValue(value: string) {
|
|
if (!value) return '';
|
|
return new Date(value).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function dateInputToISOString(value: string) {
|
|
return new Date(`${value}T00:00:00+08:00`).toISOString();
|
|
}
|
|
|
|
function ProductTracesPage() {
|
|
const [traces, setTraces] = useState<ProductTrace[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [limit, setLimit] = useState(10);
|
|
const [total, setTotal] = useState(0);
|
|
const [search, setSearch] = useState('');
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingTrace, setEditingTrace] = useState<ProductTrace | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [wechatFile, setWechatFile] = useState<File | null>(null);
|
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
const [form] = Form.useForm<ProductTraceFormValues>();
|
|
|
|
const [qrModalOpen, setQrModalOpen] = useState(false);
|
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
const [qrUrl, setQrUrl] = useState('');
|
|
const [selectedSerialNumber, setSelectedSerialNumber] = useState('');
|
|
|
|
const loadTraces = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await productTracesApi.list({
|
|
page,
|
|
limit,
|
|
search: search || undefined,
|
|
});
|
|
setTraces(result.data || []);
|
|
setTotal(result.pagination?.total || 0);
|
|
} catch (err: any) {
|
|
message.error(err?.response?.data?.message || err.message || '加载产品溯源失败');
|
|
setTraces([]);
|
|
setTotal(0);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadTraces();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [page, limit, search]);
|
|
|
|
const resetFormState = () => {
|
|
form.resetFields();
|
|
setEditingTrace(null);
|
|
setWechatFile(null);
|
|
setFileList([]);
|
|
};
|
|
|
|
const openCreate = () => {
|
|
resetFormState();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = (trace: ProductTrace) => {
|
|
setEditingTrace(trace);
|
|
setWechatFile(null);
|
|
setFileList([]);
|
|
form.setFieldsValue({
|
|
companyName: trace.companyName,
|
|
companyAddress: trace.companyAddress,
|
|
companyPhone: trace.companyPhone,
|
|
deviceInfo: trace.deviceInfo,
|
|
warrantyPeriod: trace.warrantyPeriod,
|
|
manufactureDate: toDateInputValue(trace.manufactureDate),
|
|
serialNumber: trace.serialNumber,
|
|
officialWebsite: trace.officialWebsite,
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const uploadWechatQrCodeIfNeeded = async (serialNumber: string) => {
|
|
if (!wechatFile) return undefined;
|
|
return productTracesApi.uploadWechatQrCode(serialNumber, wechatFile);
|
|
};
|
|
|
|
const handleSave = async (values: ProductTraceFormValues) => {
|
|
setSaving(true);
|
|
try {
|
|
let saved: ProductTrace;
|
|
if (editingTrace) {
|
|
const payload: UpdateProductTraceRequest = {
|
|
companyName: values.companyName,
|
|
companyAddress: values.companyAddress,
|
|
companyPhone: values.companyPhone,
|
|
deviceInfo: values.deviceInfo,
|
|
warrantyPeriod: values.warrantyPeriod,
|
|
manufactureDate: dateInputToISOString(values.manufactureDate),
|
|
officialWebsite: values.officialWebsite || '',
|
|
};
|
|
saved = await productTracesApi.update(editingTrace.serialNumber, payload);
|
|
} else {
|
|
const payload: CreateProductTraceRequest = {
|
|
...values,
|
|
officialWebsite: values.officialWebsite || '',
|
|
serialNumber: values.serialNumber.trim(),
|
|
manufactureDate: dateInputToISOString(values.manufactureDate),
|
|
};
|
|
saved = await productTracesApi.create(payload);
|
|
}
|
|
|
|
const uploaded = await uploadWechatQrCodeIfNeeded(saved.serialNumber);
|
|
if (uploaded) {
|
|
saved = uploaded;
|
|
}
|
|
|
|
message.success(editingTrace ? '保存成功' : `创建成功:${saved.serialNumber}`);
|
|
setModalOpen(false);
|
|
resetFormState();
|
|
loadTraces();
|
|
} catch (err: any) {
|
|
message.error(err?.response?.data?.message || err.message || '保存失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateQrCode = async (trace: ProductTrace) => {
|
|
try {
|
|
const result = await productTracesApi.generateQrCode(trace.serialNumber);
|
|
setQrCodeDataUrl(result.qrCodeData);
|
|
setQrUrl(result.queryUrl);
|
|
setSelectedSerialNumber(trace.serialNumber);
|
|
setQrModalOpen(true);
|
|
} catch (err: any) {
|
|
message.error(err?.response?.data?.message || err.message || '生成二维码失败');
|
|
}
|
|
};
|
|
|
|
const handleDownloadQrCode = () => {
|
|
if (!qrCodeDataUrl || !selectedSerialNumber) return;
|
|
const link = document.createElement('a');
|
|
link.download = `${selectedSerialNumber}.png`;
|
|
link.href = qrCodeDataUrl;
|
|
link.click();
|
|
};
|
|
|
|
const handleRevoke = (trace: ProductTrace) => {
|
|
Modal.confirm({
|
|
title: '停用产品溯源',
|
|
content: `确定要停用产品序列号 ${trace.serialNumber} 吗?`,
|
|
okText: '停用',
|
|
okType: 'danger',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await productTracesApi.revoke(trace.serialNumber);
|
|
message.success('已停用');
|
|
loadTraces();
|
|
} catch (err: any) {
|
|
message.error(err?.response?.data?.message || err.message || '停用失败');
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleDelete = (trace: ProductTrace) => {
|
|
Modal.confirm({
|
|
title: '删除产品溯源',
|
|
content: `确定要删除产品序列号 ${trace.serialNumber} 吗?此操作不可恢复。`,
|
|
okText: '删除',
|
|
okType: 'danger',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await productTracesApi.delete(trace.serialNumber);
|
|
message.success('删除成功');
|
|
loadTraces();
|
|
} catch (err: any) {
|
|
message.error(err?.response?.data?.message || err.message || '删除失败');
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '产品序列号',
|
|
dataIndex: 'serialNumber',
|
|
key: 'serialNumber',
|
|
width: 180,
|
|
render: (value: string) => (
|
|
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>{value}</span>
|
|
),
|
|
},
|
|
{
|
|
title: '企业名称',
|
|
dataIndex: 'companyName',
|
|
key: 'companyName',
|
|
},
|
|
{
|
|
title: '设备信息',
|
|
dataIndex: 'deviceInfo',
|
|
key: 'deviceInfo',
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: '质保期',
|
|
dataIndex: 'warrantyPeriod',
|
|
key: 'warrantyPeriod',
|
|
width: 140,
|
|
},
|
|
{
|
|
title: '出厂日期',
|
|
dataIndex: 'manufactureDate',
|
|
key: 'manufactureDate',
|
|
width: 140,
|
|
render: (value: string) => formatDate(value),
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'isActive',
|
|
key: 'isActive',
|
|
width: 90,
|
|
render: (value: boolean) => <Tag color={value ? 'green' : 'red'}>{value ? '有效' : '停用'}</Tag>,
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'actions',
|
|
width: 260,
|
|
render: (_: unknown, record: ProductTrace) => (
|
|
<Space>
|
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openEdit(record)}>
|
|
详情
|
|
</Button>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<QrcodeOutlined />}
|
|
onClick={() => handleGenerateQrCode(record)}
|
|
>
|
|
二维码
|
|
</Button>
|
|
{record.isActive && (
|
|
<Button type="link" size="small" danger icon={<StopOutlined />} onClick={() => handleRevoke(record)}>
|
|
停用
|
|
</Button>
|
|
)}
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
|
|
删除
|
|
</Button>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Card
|
|
title={
|
|
<Space>
|
|
<QrcodeOutlined />
|
|
<span>产品溯源</span>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
|
新建产品溯源
|
|
</Button>
|
|
}
|
|
>
|
|
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
|
|
<Input.Search
|
|
placeholder="搜索企业/设备/产品序列号"
|
|
allowClear
|
|
style={{ width: 280 }}
|
|
onSearch={(value) => {
|
|
setPage(1);
|
|
setSearch(value);
|
|
}}
|
|
onChange={(event) => {
|
|
if (!event.target.value) {
|
|
setPage(1);
|
|
setSearch('');
|
|
}
|
|
}}
|
|
/>
|
|
</Space>
|
|
<Table columns={columns} dataSource={traces} rowKey="serialNumber" loading={loading} pagination={false} />
|
|
<div style={{ marginTop: 16 }}>
|
|
<Pagination
|
|
current={page}
|
|
pageSize={limit}
|
|
total={total}
|
|
showSizeChanger
|
|
showTotal={(value) => `共计 ${value} 条记录`}
|
|
onChange={(newPage, newLimit) => {
|
|
setPage(newPage);
|
|
setLimit(newLimit);
|
|
}}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Modal
|
|
title={editingTrace ? '产品溯源详情' : '新建产品溯源'}
|
|
open={modalOpen}
|
|
onCancel={() => {
|
|
setModalOpen(false);
|
|
resetFormState();
|
|
}}
|
|
footer={null}
|
|
width={680}
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={handleSave}>
|
|
<Form.Item name="companyName" label="企业名称" rules={[{ required: true, message: '请输入企业名称' }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="companyAddress" label="地址" rules={[{ required: true, message: '请输入地址' }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="companyPhone" label="电话" rules={[{ required: true, message: '请输入电话' }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="deviceInfo" label="设备信息" rules={[{ required: true, message: '请输入设备信息' }]}>
|
|
<Input.TextArea rows={3} />
|
|
</Form.Item>
|
|
<Form.Item name="warrantyPeriod" label="质保期" rules={[{ required: true, message: '请输入质保期' }]}>
|
|
<Input placeholder="例如:1 年 / 2027-06-05 前" />
|
|
</Form.Item>
|
|
<Form.Item name="manufactureDate" label="出厂日期" rules={[{ required: true, message: '请选择出厂日期' }]}>
|
|
<Input type="date" />
|
|
</Form.Item>
|
|
<Form.Item name="serialNumber" label="产品序列号" rules={[{ required: true, message: '请输入产品序列号' }]}>
|
|
<Input disabled={Boolean(editingTrace)} />
|
|
</Form.Item>
|
|
<Form.Item name="officialWebsite" label="官网链接(可选)" rules={[{ type: 'url', message: '请输入有效链接' }]}>
|
|
<Input prefix={<LinkOutlined />} placeholder="https://example.com" />
|
|
</Form.Item>
|
|
<Form.Item label="公众号二维码(可选)">
|
|
<Upload
|
|
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
|
|
listType="picture-card"
|
|
maxCount={1}
|
|
fileList={fileList}
|
|
beforeUpload={(file) => {
|
|
setWechatFile(file);
|
|
setFileList([file]);
|
|
return false;
|
|
}}
|
|
onRemove={() => {
|
|
setWechatFile(null);
|
|
setFileList([]);
|
|
}}
|
|
>
|
|
{fileList.length === 0 ? (
|
|
<div>
|
|
<UploadOutlined />
|
|
<div style={{ marginTop: 8 }}>上传</div>
|
|
</div>
|
|
) : null}
|
|
</Upload>
|
|
{editingTrace?.wechatQrCode && !wechatFile && (
|
|
<Image src={editingTrace.wechatQrCode} alt="公众号二维码" width={96} height={96} />
|
|
)}
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
|
<Button onClick={() => setModalOpen(false)}>取消</Button>
|
|
<Button type="primary" htmlType="submit" loading={saving}>
|
|
保存
|
|
</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="产品溯源二维码"
|
|
open={qrModalOpen}
|
|
onCancel={() => setQrModalOpen(false)}
|
|
footer={[
|
|
<Button key="download" onClick={handleDownloadQrCode}>
|
|
下载二维码
|
|
</Button>,
|
|
<Button key="close" type="primary" onClick={() => setQrModalOpen(false)}>
|
|
关闭
|
|
</Button>,
|
|
]}
|
|
>
|
|
<div style={{ textAlign: 'center' }}>
|
|
{qrCodeDataUrl && (
|
|
<>
|
|
<img src={qrCodeDataUrl} alt="产品溯源二维码" style={{ width: 220, height: 220 }} />
|
|
<p style={{ marginTop: 12, fontFamily: 'monospace', color: '#165DFF', fontWeight: 700 }}>
|
|
{selectedSerialNumber}
|
|
</p>
|
|
<p style={{ fontSize: 12, color: '#888', wordBreak: 'break-all' }}>{qrUrl}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ProductTracesPage;
|