feat: add product trace UI
This commit is contained in:
@@ -7,6 +7,8 @@ import DashboardPage from './pages/Dashboard';
|
||||
import ManagePage from './pages/Manage';
|
||||
import ProfilePage from './pages/Profile';
|
||||
import EmployeeSerialsPage from './pages/EmployeeSerials';
|
||||
import ProductTracesPage from './pages/ProductTraces';
|
||||
import ProductTracePublicPage from './pages/ProductTracePublic';
|
||||
import AftersalesPage from './pages/Aftersales';
|
||||
import AftersalesDetailPage from './pages/AftersalesDetail';
|
||||
import AftersalesConfirmPage from './pages/AftersalesConfirm';
|
||||
@@ -50,6 +52,7 @@ function App() {
|
||||
</PublicRoute>
|
||||
} />
|
||||
<Route path="/query" element={<PublicQueryPage />} />
|
||||
<Route path="/product-traces/:serialNumber" element={<ProductTracePublicPage />} />
|
||||
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
|
||||
<Route path="/project-orders/:serialNumber" element={<ProjectOrderCompletePage />} />
|
||||
|
||||
@@ -59,6 +62,7 @@ function App() {
|
||||
<Route path="/admin/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/admin/manage" element={<ManagePage />} />
|
||||
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
|
||||
<Route path="/admin/product-traces" element={<ProductTracesPage />} />
|
||||
<Route path="/admin/aftersales" element={<AftersalesPage />} />
|
||||
<Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} />
|
||||
<Route path="/admin/project-orders" element={<ProjectOrdersPage />} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
IdcardOutlined,
|
||||
ProjectOutlined,
|
||||
QrcodeOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { authApi } from '@/services/api';
|
||||
@@ -53,6 +54,12 @@ function AdminLayout() {
|
||||
label: '员工管理',
|
||||
onClick: () => navigate('/admin/employee-serials'),
|
||||
},
|
||||
{
|
||||
key: 'product-traces',
|
||||
icon: <QrcodeOutlined />,
|
||||
label: '产品溯源',
|
||||
onClick: () => navigate('/admin/product-traces'),
|
||||
},
|
||||
{
|
||||
key: 'project-orders',
|
||||
icon: <ProjectOutlined />,
|
||||
@@ -125,6 +132,7 @@ function AdminLayout() {
|
||||
if (path.includes('/dashboard')) return 'dashboard';
|
||||
if (path.includes('/manage')) return 'manage';
|
||||
if (path.includes('/employee-serials')) return 'employee-serials';
|
||||
if (path.includes('/product-traces')) return 'product-traces';
|
||||
if (path.includes('/project-orders')) return 'project-orders';
|
||||
if (path.includes('/aftersales')) return 'aftersales';
|
||||
if (path.includes('/profile')) return 'profile';
|
||||
@@ -136,6 +144,7 @@ function AdminLayout() {
|
||||
if (path.includes('/dashboard')) return '控制台';
|
||||
if (path.includes('/manage')) return '企业管理';
|
||||
if (path.includes('/employee-serials')) return '员工管理';
|
||||
if (path.includes('/product-traces')) return '产品溯源';
|
||||
if (path.includes('/project-orders')) return '项目工单';
|
||||
if (path.includes('/aftersales')) return '售后工单';
|
||||
if (path.includes('/profile')) return '用户资料';
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Card, Image, Result, Spin, Tag } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
GlobalOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { productTracesApi } from '@/services/api';
|
||||
import type { ProductTrace } from '@/types';
|
||||
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
|
||||
import './styles/PublicQuery.css';
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
function ProductTracePublicPage() {
|
||||
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
|
||||
const [trace, setTrace] = useState<ProductTrace | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTrace = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await productTracesApi.publicQuery(serialNumber);
|
||||
setTrace(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || err.message || '查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (serialNumber) {
|
||||
loadTrace();
|
||||
}
|
||||
}, [serialNumber]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<Card className="query-card" bordered={false}>
|
||||
<div className="loading-container">
|
||||
<Spin size="large" />
|
||||
<p>正在加载产品溯源信息...</p>
|
||||
</div>
|
||||
</Card>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !trace) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<Card className="query-card" bordered={false}>
|
||||
<Result
|
||||
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
|
||||
title="产品溯源不存在"
|
||||
subTitle={error || '请检查二维码是否正确'}
|
||||
/>
|
||||
</Card>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<Card className="result-card show" bordered={false}>
|
||||
<div className="result-header">
|
||||
<PublicLogo />
|
||||
</div>
|
||||
<Result
|
||||
icon={
|
||||
trace.isActive ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />
|
||||
)
|
||||
}
|
||||
title="产品溯源信息"
|
||||
subTitle={trace.isActive ? '该产品溯源信息有效' : '该产品溯源信息已停用'}
|
||||
/>
|
||||
|
||||
<div className="result-details">
|
||||
<div className="detail-item">
|
||||
<span className="label">企业名称</span>
|
||||
<span className="value">{trace.companyName}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">地址</span>
|
||||
<span className="value">{trace.companyAddress}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">电话</span>
|
||||
<span className="value">{trace.companyPhone}</span>
|
||||
</div>
|
||||
<div className="detail-item detail-item-block">
|
||||
<span className="label">设备信息</span>
|
||||
<span className="value value-block">{trace.deviceInfo}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">质保期</span>
|
||||
<span className="value">{trace.warrantyPeriod}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">出厂日期</span>
|
||||
<span className="value">{formatDate(trace.manufactureDate)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">产品序列号</span>
|
||||
<span className="value serial">{trace.serialNumber}</span>
|
||||
</div>
|
||||
{trace.officialWebsite && (
|
||||
<div className="detail-item">
|
||||
<span className="label">官网链接</span>
|
||||
<span className="value">
|
||||
<Button
|
||||
type="link"
|
||||
href={trace.officialWebsite}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
icon={<GlobalOutlined />}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
访问官网
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{trace.wechatQrCode && (
|
||||
<div className="detail-item detail-item-block">
|
||||
<span className="label">公众号二维码</span>
|
||||
<Image src={trace.wechatQrCode} alt="公众号二维码" width={160} />
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="label">状态</span>
|
||||
<span className="value status">
|
||||
<Tag color={trace.isActive ? 'green' : 'red'}>{trace.isActive ? '有效' : '停用'}</Tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductTracePublicPage;
|
||||
@@ -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;
|
||||
@@ -109,6 +109,19 @@
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.detail-item.detail-item-block {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item .value.value-block {
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item .value.serial {
|
||||
color: #165DFF;
|
||||
font-family: 'Courier New', monospace;
|
||||
|
||||
@@ -3,6 +3,11 @@ import type {
|
||||
User,
|
||||
EmployeeSerial,
|
||||
EmployeeSerialResponse,
|
||||
ProductTrace,
|
||||
ProductTraceListFilter,
|
||||
ProductTraceListResponse,
|
||||
CreateProductTraceRequest,
|
||||
UpdateProductTraceRequest,
|
||||
AftersalesOrder,
|
||||
AftersalesPublicView,
|
||||
AftersalesListFilter,
|
||||
@@ -209,6 +214,92 @@ export const companyApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const productTracesApi = {
|
||||
create: async (data: CreateProductTraceRequest) => {
|
||||
const response = await apiClient.post('/product-traces', data);
|
||||
if (response.data.trace) {
|
||||
return response.data.trace as ProductTrace;
|
||||
}
|
||||
throw new Error(response.data.error || '创建产品溯源失败');
|
||||
},
|
||||
|
||||
list: async (filter?: ProductTraceListFilter) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.page && filter.page > 1) params.append('page', String(filter.page));
|
||||
if (filter?.limit && filter.limit !== 20) params.append('limit', String(filter.limit));
|
||||
if (filter?.search) params.append('search', filter.search);
|
||||
const url = params.toString() ? `/product-traces?${params.toString()}` : '/product-traces';
|
||||
const response = await apiClient.get(url);
|
||||
if (response.data) {
|
||||
return response.data as ProductTraceListResponse;
|
||||
}
|
||||
throw new Error('获取产品溯源列表失败');
|
||||
},
|
||||
|
||||
get: async (serialNumber: string) => {
|
||||
const response = await apiClient.get(`/product-traces/${encodeURIComponent(serialNumber)}`);
|
||||
if (response.data.trace) {
|
||||
return response.data.trace as ProductTrace;
|
||||
}
|
||||
throw new Error(response.data.error || '查询产品溯源失败');
|
||||
},
|
||||
|
||||
update: async (serialNumber: string, data: UpdateProductTraceRequest) => {
|
||||
const response = await apiClient.patch(`/product-traces/${encodeURIComponent(serialNumber)}`, data);
|
||||
if (response.data.trace) {
|
||||
return response.data.trace as ProductTrace;
|
||||
}
|
||||
throw new Error(response.data.error || '更新产品溯源失败');
|
||||
},
|
||||
|
||||
generateQrCode: async (serialNumber: string, baseUrl?: string) => {
|
||||
const response = await apiClient.post(`/product-traces/${encodeURIComponent(serialNumber)}/qrcode`, {
|
||||
baseUrl,
|
||||
});
|
||||
if (response.data.qrCodeData) {
|
||||
return response.data as { qrCodeData: string; queryUrl: string };
|
||||
}
|
||||
throw new Error(response.data.error || '生成二维码失败');
|
||||
},
|
||||
|
||||
uploadWechatQrCode: async (serialNumber: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post(
|
||||
`/product-traces/${encodeURIComponent(serialNumber)}/wechat-qrcode`,
|
||||
formData,
|
||||
);
|
||||
if (response.data.trace) {
|
||||
return response.data.trace as ProductTrace;
|
||||
}
|
||||
throw new Error(response.data.error || '上传公众号二维码失败');
|
||||
},
|
||||
|
||||
revoke: async (serialNumber: string) => {
|
||||
const response = await apiClient.post(`/product-traces/${encodeURIComponent(serialNumber)}/revoke`);
|
||||
if (response.data.trace) {
|
||||
return response.data.trace as ProductTrace;
|
||||
}
|
||||
throw new Error(response.data.error || '停用产品溯源失败');
|
||||
},
|
||||
|
||||
delete: async (serialNumber: string) => {
|
||||
const response = await apiClient.delete(`/product-traces/${encodeURIComponent(serialNumber)}`);
|
||||
if (response.data.message) {
|
||||
return true;
|
||||
}
|
||||
throw new Error(response.data.error || '删除产品溯源失败');
|
||||
},
|
||||
|
||||
publicQuery: async (serialNumber: string) => {
|
||||
const response = await apiClient.get(`/product-traces/${encodeURIComponent(serialNumber)}/query`);
|
||||
if (response.data.trace) {
|
||||
return response.data.trace as ProductTrace;
|
||||
}
|
||||
throw new Error(response.data.error || '查询产品溯源失败');
|
||||
},
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: async () => {
|
||||
// 后端路径是 /api/companies/stats/overview
|
||||
|
||||
@@ -63,6 +63,58 @@ export interface Serial {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductTrace {
|
||||
id: number;
|
||||
companyName: string;
|
||||
companyAddress: string;
|
||||
companyPhone: string;
|
||||
deviceInfo: string;
|
||||
warrantyPeriod: string;
|
||||
manufactureDate: string;
|
||||
serialNumber: string;
|
||||
officialWebsite?: string;
|
||||
wechatQrCode?: string;
|
||||
isActive: boolean;
|
||||
createdBy?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
creator?: User;
|
||||
}
|
||||
|
||||
export interface CreateProductTraceRequest {
|
||||
companyName: string;
|
||||
companyAddress: string;
|
||||
companyPhone: string;
|
||||
deviceInfo: string;
|
||||
warrantyPeriod: string;
|
||||
manufactureDate: string;
|
||||
serialNumber: string;
|
||||
officialWebsite?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProductTraceRequest {
|
||||
companyName?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
deviceInfo?: string;
|
||||
warrantyPeriod?: string;
|
||||
manufactureDate?: string;
|
||||
officialWebsite?: string;
|
||||
wechatQrCode?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductTraceListFilter {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ProductTraceListResponse {
|
||||
data: ProductTrace[];
|
||||
pagination: EmployeeSerialPagination;
|
||||
}
|
||||
|
||||
export interface GenerateSerialRequest {
|
||||
companyName: string;
|
||||
serialOption: 'auto' | 'custom';
|
||||
|
||||
Reference in New Issue
Block a user