Files
frontend/src/pages/ProductTraces.tsx
T
2026-06-05 17:43:05 +08:00

470 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Dropdown,
Form,
Image,
Input,
Modal,
Pagination,
Space,
Table,
Tag,
Upload,
message,
} from 'antd';
import {
DeleteOutlined,
EyeOutlined,
LinkOutlined,
MoreOutlined,
PlusOutlined,
QrcodeOutlined,
StopOutlined,
UploadOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { productTracesApi } from '@/services/api';
import type { CreateProductTraceRequest, ProductTrace, UpdateProductTraceRequest } from '@/types';
type ProductTraceFormValues = Omit<CreateProductTraceRequest, 'manufactureDate'> & {
manufactureDate: Dayjs;
};
function formatDate(value: string) {
if (!value) return '-';
return new Date(value).toLocaleDateString('zh-CN');
}
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: dayjs(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: values.manufactureDate.toDate().toISOString(),
officialWebsite: values.officialWebsite || '',
};
saved = await productTracesApi.update(editingTrace.serialNumber, payload);
} else {
const payload: CreateProductTraceRequest = {
...values,
officialWebsite: values.officialWebsite || '',
serialNumber: values.serialNumber.trim(),
manufactureDate: values.manufactureDate.toDate().toISOString(),
};
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: 180,
render: (_: unknown, record: ProductTrace) => {
const moreItems = [
...(record.isActive
? [
{
key: 'revoke',
label: '停用',
danger: true,
icon: <StopOutlined />,
},
]
: []),
{
key: 'delete',
label: '删除',
danger: true,
icon: <DeleteOutlined />,
},
];
return (
<Space size={0}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openEdit(record)}>
</Button>
<Button
type="link"
size="small"
icon={<QrcodeOutlined />}
onClick={() => handleGenerateQrCode(record)}
>
</Button>
<Dropdown
menu={{
items: moreItems,
onClick: ({ key }) => {
if (key === 'revoke') {
handleRevoke(record);
}
if (key === 'delete') {
handleDelete(record);
}
},
}}
trigger={['click']}
>
<Button type="link" size="small" icon={<MoreOutlined />} aria-label="更多操作" />
</Dropdown>
</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: '请选择出厂日期' }]}>
<DatePicker style={{ width: '100%' }} />
</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;