From 79ce72f8ea5f00d5e9d968f5381436db594faf45 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Fri, 5 Jun 2026 17:21:14 +0800 Subject: [PATCH] feat: add product trace UI --- src/App.tsx | 4 + src/components/AdminLayout.tsx | 9 + src/pages/ProductTracePublic.tsx | 153 +++++++++++ src/pages/ProductTraces.tsx | 445 +++++++++++++++++++++++++++++++ src/pages/styles/PublicQuery.css | 15 +- src/services/api.ts | 91 +++++++ src/types/index.ts | 52 ++++ 7 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/pages/ProductTracePublic.tsx create mode 100644 src/pages/ProductTraces.tsx diff --git a/src/App.tsx b/src/App.tsx index b708289..a840acc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> @@ -59,6 +62,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 1638eb5..41995ab 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -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: , + label: '产品溯源', + onClick: () => navigate('/admin/product-traces'), + }, { key: 'project-orders', icon: , @@ -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 '用户资料'; diff --git a/src/pages/ProductTracePublic.tsx b/src/pages/ProductTracePublic.tsx new file mode 100644 index 0000000..48bb66e --- /dev/null +++ b/src/pages/ProductTracePublic.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + +
+ +

正在加载产品溯源信息...

+
+
+
+ ); + } + + if (error || !trace) { + return ( + + + } + title="产品溯源不存在" + subTitle={error || '请检查二维码是否正确'} + /> + + + ); + } + + return ( + + +
+ +
+ + ) : ( + + ) + } + title="产品溯源信息" + subTitle={trace.isActive ? '该产品溯源信息有效' : '该产品溯源信息已停用'} + /> + +
+
+ 企业名称 + {trace.companyName} +
+
+ 地址 + {trace.companyAddress} +
+
+ 电话 + {trace.companyPhone} +
+
+ 设备信息 + {trace.deviceInfo} +
+
+ 质保期 + {trace.warrantyPeriod} +
+
+ 出厂日期 + {formatDate(trace.manufactureDate)} +
+
+ 产品序列号 + {trace.serialNumber} +
+ {trace.officialWebsite && ( +
+ 官网链接 + + + +
+ )} + {trace.wechatQrCode && ( +
+ 公众号二维码 + 公众号二维码 +
+ )} +
+ 状态 + + {trace.isActive ? '有效' : '停用'} + +
+
+
+
+ ); +} + +export default ProductTracePublicPage; diff --git a/src/pages/ProductTraces.tsx b/src/pages/ProductTraces.tsx new file mode 100644 index 0000000..2cb5116 --- /dev/null +++ b/src/pages/ProductTraces.tsx @@ -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 & { + 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([]); + 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(null); + const [saving, setSaving] = useState(false); + const [wechatFile, setWechatFile] = useState(null); + const [fileList, setFileList] = useState([]); + const [form] = Form.useForm(); + + 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) => ( + {value} + ), + }, + { + 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) => {value ? '有效' : '停用'}, + }, + { + title: '操作', + key: 'actions', + width: 260, + render: (_: unknown, record: ProductTrace) => ( + + + + {record.isActive && ( + + )} + + + ), + }, + ]; + + return ( +
+ + + 产品溯源 + + } + extra={ + + } + > + + { + setPage(1); + setSearch(value); + }} + onChange={(event) => { + if (!event.target.value) { + setPage(1); + setSearch(''); + } + }} + /> + + +
+ `共计 ${value} 条记录`} + onChange={(newPage, newLimit) => { + setPage(newPage); + setLimit(newLimit); + }} + /> +
+ + + { + setModalOpen(false); + resetFormState(); + }} + footer={null} + width={680} + > +
+ + + + + + + + + + + + + + + + + + + + + + + } placeholder="https://example.com" /> + + + { + setWechatFile(file); + setFileList([file]); + return false; + }} + onRemove={() => { + setWechatFile(null); + setFileList([]); + }} + > + {fileList.length === 0 ? ( +
+ +
上传
+
+ ) : null} +
+ {editingTrace?.wechatQrCode && !wechatFile && ( + + )} +
+ + + + + + + +
+ + setQrModalOpen(false)} + footer={[ + , + , + ]} + > +
+ {qrCodeDataUrl && ( + <> + 产品溯源二维码 +

+ {selectedSerialNumber} +

+

{qrUrl}

+ + )} +
+
+ + ); +} + +export default ProductTracesPage; diff --git a/src/pages/styles/PublicQuery.css b/src/pages/styles/PublicQuery.css index 36d67c7..2337dd9 100644 --- a/src/pages/styles/PublicQuery.css +++ b/src/pages/styles/PublicQuery.css @@ -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; @@ -146,4 +159,4 @@ .copyright img { width: 16px; height: 16px; -} \ No newline at end of file +} diff --git a/src/services/api.ts b/src/services/api.ts index 29a1fee..bdc547f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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 diff --git a/src/types/index.ts b/src/types/index.ts index 9ea8c73..d67f7ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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';