feat: add product trace UI

This commit is contained in:
Frudrax Cheng
2026-06-05 17:21:14 +08:00
parent da11bce1fa
commit 79ce72f8ea
7 changed files with 768 additions and 1 deletions
+4
View File
@@ -7,6 +7,8 @@ import DashboardPage from './pages/Dashboard';
import ManagePage from './pages/Manage'; import ManagePage from './pages/Manage';
import ProfilePage from './pages/Profile'; import ProfilePage from './pages/Profile';
import EmployeeSerialsPage from './pages/EmployeeSerials'; import EmployeeSerialsPage from './pages/EmployeeSerials';
import ProductTracesPage from './pages/ProductTraces';
import ProductTracePublicPage from './pages/ProductTracePublic';
import AftersalesPage from './pages/Aftersales'; import AftersalesPage from './pages/Aftersales';
import AftersalesDetailPage from './pages/AftersalesDetail'; import AftersalesDetailPage from './pages/AftersalesDetail';
import AftersalesConfirmPage from './pages/AftersalesConfirm'; import AftersalesConfirmPage from './pages/AftersalesConfirm';
@@ -50,6 +52,7 @@ function App() {
</PublicRoute> </PublicRoute>
} /> } />
<Route path="/query" element={<PublicQueryPage />} /> <Route path="/query" element={<PublicQueryPage />} />
<Route path="/product-traces/:serialNumber" element={<ProductTracePublicPage />} />
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} /> <Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
<Route path="/project-orders/:serialNumber" element={<ProjectOrderCompletePage />} /> <Route path="/project-orders/:serialNumber" element={<ProjectOrderCompletePage />} />
@@ -59,6 +62,7 @@ function App() {
<Route path="/admin/dashboard" element={<DashboardPage />} /> <Route path="/admin/dashboard" element={<DashboardPage />} />
<Route path="/admin/manage" element={<ManagePage />} /> <Route path="/admin/manage" element={<ManagePage />} />
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} /> <Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
<Route path="/admin/product-traces" element={<ProductTracesPage />} />
<Route path="/admin/aftersales" element={<AftersalesPage />} /> <Route path="/admin/aftersales" element={<AftersalesPage />} />
<Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} /> <Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} />
<Route path="/admin/project-orders" element={<ProjectOrdersPage />} /> <Route path="/admin/project-orders" element={<ProjectOrdersPage />} />
+9
View File
@@ -9,6 +9,7 @@ import {
ExclamationCircleOutlined, ExclamationCircleOutlined,
IdcardOutlined, IdcardOutlined,
ProjectOutlined, ProjectOutlined,
QrcodeOutlined,
ToolOutlined, ToolOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { authApi } from '@/services/api'; import { authApi } from '@/services/api';
@@ -53,6 +54,12 @@ function AdminLayout() {
label: '员工管理', label: '员工管理',
onClick: () => navigate('/admin/employee-serials'), onClick: () => navigate('/admin/employee-serials'),
}, },
{
key: 'product-traces',
icon: <QrcodeOutlined />,
label: '产品溯源',
onClick: () => navigate('/admin/product-traces'),
},
{ {
key: 'project-orders', key: 'project-orders',
icon: <ProjectOutlined />, icon: <ProjectOutlined />,
@@ -125,6 +132,7 @@ function AdminLayout() {
if (path.includes('/dashboard')) return 'dashboard'; if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/manage')) return 'manage'; if (path.includes('/manage')) return 'manage';
if (path.includes('/employee-serials')) return 'employee-serials'; 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('/project-orders')) return 'project-orders';
if (path.includes('/aftersales')) return 'aftersales'; if (path.includes('/aftersales')) return 'aftersales';
if (path.includes('/profile')) return 'profile'; if (path.includes('/profile')) return 'profile';
@@ -136,6 +144,7 @@ function AdminLayout() {
if (path.includes('/dashboard')) return '控制台'; if (path.includes('/dashboard')) return '控制台';
if (path.includes('/manage')) return '企业管理'; if (path.includes('/manage')) return '企业管理';
if (path.includes('/employee-serials')) return '员工管理'; if (path.includes('/employee-serials')) return '员工管理';
if (path.includes('/product-traces')) return '产品溯源';
if (path.includes('/project-orders')) return '项目工单'; if (path.includes('/project-orders')) return '项目工单';
if (path.includes('/aftersales')) return '售后工单'; if (path.includes('/aftersales')) return '售后工单';
if (path.includes('/profile')) return '用户资料'; if (path.includes('/profile')) return '用户资料';
+153
View File
@@ -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;
+445
View File
@@ -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;
+13
View File
@@ -109,6 +109,19 @@
color: #111827; 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 { .detail-item .value.serial {
color: #165DFF; color: #165DFF;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
+91
View File
@@ -3,6 +3,11 @@ import type {
User, User,
EmployeeSerial, EmployeeSerial,
EmployeeSerialResponse, EmployeeSerialResponse,
ProductTrace,
ProductTraceListFilter,
ProductTraceListResponse,
CreateProductTraceRequest,
UpdateProductTraceRequest,
AftersalesOrder, AftersalesOrder,
AftersalesPublicView, AftersalesPublicView,
AftersalesListFilter, 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 = { export const dashboardApi = {
getStats: async () => { getStats: async () => {
// 后端路径是 /api/companies/stats/overview // 后端路径是 /api/companies/stats/overview
+52
View File
@@ -63,6 +63,58 @@ export interface Serial {
createdAt: string; 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 { export interface GenerateSerialRequest {
companyName: string; companyName: string;
serialOption: 'auto' | 'custom'; serialOption: 'auto' | 'custom';