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 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,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 '用户资料';
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user