feat: add product trace UI
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user