diff --git a/apps/frontend-react/package-lock.json b/apps/frontend-react/package-lock.json index 512f8932..33ade3b1 100644 --- a/apps/frontend-react/package-lock.json +++ b/apps/frontend-react/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.90.12", "antd": "^6.1.1", "axios": "^1.13.2", + "dayjs": "^1.11.21", "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/apps/frontend-react/package.json b/apps/frontend-react/package.json index 6d5474d6..1d8d495c 100644 --- a/apps/frontend-react/package.json +++ b/apps/frontend-react/package.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.90.12", "antd": "^6.1.1", "axios": "^1.13.2", + "dayjs": "^1.11.21", "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/apps/frontend-react/src/App.tsx b/apps/frontend-react/src/App.tsx index 1e01f61e..adc06cbe 100644 --- a/apps/frontend-react/src/App.tsx +++ b/apps/frontend-react/src/App.tsx @@ -7,6 +7,9 @@ import AdminLayout from '@/layouts/AdminLayout' const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) +const AdminCloudtentaclesRecordsPage = lazy( + () => import('@/pages/admin/AdminCloudtentaclesRecordsPage'), +) const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage')) const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage')) const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage')) @@ -61,6 +64,7 @@ export default function App() { } /> } /> } /> + } /> }> } /> } /> diff --git a/apps/frontend-react/src/pages/admin/AdminCloudtentaclesRecordsPage.tsx b/apps/frontend-react/src/pages/admin/AdminCloudtentaclesRecordsPage.tsx new file mode 100644 index 00000000..9bceb1b0 --- /dev/null +++ b/apps/frontend-react/src/pages/admin/AdminCloudtentaclesRecordsPage.tsx @@ -0,0 +1,666 @@ +import { + DownloadOutlined, + FileImageOutlined, + ReloadOutlined, + SearchOutlined, +} from '@ant-design/icons' +import { + Alert, + Button, + Card, + DatePicker, + Image as AntdImage, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import dayjs from 'dayjs' +import type { Dayjs } from 'dayjs' +import { useEffect, useMemo, useState } from 'react' + +import PageHeader from '@/components/admin/PageHeader' +import { showError, showSuccess } from '@/lib/feedback' +import { + fetchAdminCloudtentaclesDeliveryRecords, + fetchAdminCloudtentaclesRecordSources, +} from '@/services/admin' +import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin' +import { getAdminToken } from '@/utils/admin-auth' +import { formatAdminDateTime } from '@/utils/admin-time' + +type RecordSourceOption = { + key: string + label: string + enabled: boolean + hasToken: boolean + loggedInAt: string +} + +type DateRangeValue = [Dayjs | null, Dayjs | null] | null + +const DEFAULT_PAGE_SIZE = 100 +const PAGE_SIZE_OPTIONS = [100, 200, 500, 1000] + +export default function AdminCloudtentaclesRecordsPage() { + const [loading, setLoading] = useState(false) + const [sourceLoading, setSourceLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [sourceKey, setSourceKey] = useState('') + const [platformOrderId, setPlatformOrderId] = useState('') + const [dateRange, setDateRange] = useState(createDefaultDateRange()) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) + const [total, setTotal] = useState(0) + const [items, setItems] = useState([]) + const [sourceOptions, setSourceOptions] = useState([]) + const [previewVisible, setPreviewVisible] = useState(false) + const [previewImageUrl, setPreviewImageUrl] = useState('') + const [previewFileName, setPreviewFileName] = useState('') + + const currentSourceLabel = useMemo(() => { + const matched = sourceOptions.find((source) => source.key === sourceKey) + return matched?.label || sourceKey || '-' + }, [sourceKey, sourceOptions]) + + const columns: TableColumnsType = [ + { + title: '商品 / 角色', + minWidth: 260, + render: (_, row) => ( +
+ + {row.name || '-'} + + 账号:{row.fulfillUser || '-'} + {row.roleName || row.roleId ? ( + + 角色:{row.roleName || '-'} + {row.roleId ? `(${row.roleId})` : ''} + + ) : null} +
+ ), + }, + { + title: '订单 / 店铺', + minWidth: 260, + render: (_, row) => ( +
+ + {row.platformOrderId || '-'} + + 订单店铺:{row.orderShopName || row.orderShopId || '-'} + 核销店铺:{row.consumeShopName || row.consumeShopId || '-'} +
+ ), + }, + { + title: '虚拟号', + minWidth: 160, + render: (_, row) => ( +
+ {row.phone || '-'} + VN {row.virtualNumberId || '-'} +
+ ), + }, + { + title: '状态', + width: 140, + render: (_, row) => ( +
+ {getStatusLabel(row.status)} + {row.localDispatchStatus ? ( + {getLocalDispatchStatusLabel(row.localDispatchStatus)} + ) : null} +
+ ), + }, + { + title: '操作人', + minWidth: 120, + render: (_, row) => row.operatorName || '-', + }, + { + title: '发货时间', + minWidth: 170, + render: (_, row) => formatAdminDateTime(row.createdAt), + }, + { + title: '记录 ID', + minWidth: 230, + render: (_, row) => ( +
+ + {row.recordId || '-'} + + {row.taskNo ? ( + + 任务:{row.taskNo} + {getMatchConfidenceLabel(row.matchConfidence) + ? ` · 匹配${getMatchConfidenceLabel(row.matchConfidence)}` + : ''} + + ) : null} +
+ ), + }, + { + title: '操作', + fixed: 'right', + width: 136, + render: (_, row) => ( + + ), + }, + ] + + useEffect(() => { + void bootstrap() + }, []) + + async function bootstrap() { + const nextSourceKey = await loadSources() + if (nextSourceKey) { + await loadRecords(1, { sourceKey: nextSourceKey }) + } + } + + async function loadSources() { + setSourceLoading(true) + + try { + const response = await fetchAdminCloudtentaclesRecordSources() + const nextSources = response.data.sources || [] + const usableSources = nextSources.filter((source) => source.enabled && source.hasToken) + const nextSourceKey = sourceKey || usableSources[0]?.key || nextSources[0]?.key || '' + setSourceOptions(nextSources) + setSourceKey(nextSourceKey) + return nextSourceKey + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 账号失败') + return '' + } finally { + setSourceLoading(false) + } + } + + async function loadRecords( + nextPage = page, + options: { + sourceKey?: string + pageSize?: number + platformOrderId?: string + dateRange?: DateRangeValue + } = {}, + ) { + const nextSourceKey = options.sourceKey ?? sourceKey + const nextPageSize = options.pageSize ?? pageSize + const nextDateRange = options.dateRange ?? dateRange + const orderKeyword = (options.platformOrderId ?? platformOrderId).trim() + + if (!nextSourceKey && !orderKeyword) { + showError('请先选择 cloudtentacles 账号') + return + } + + const [startDate, endDate] = nextDateRange || [] + if (!startDate || !endDate) { + showError('请选择查询时间范围') + return + } + + setLoading(true) + setErrorMessage('') + + try { + const response = await fetchAdminCloudtentaclesDeliveryRecords({ + sourceKey: nextSourceKey || undefined, + page: nextPage, + size: nextPageSize, + startDate: formatQueryDateTime(startDate), + endDate: formatQueryDateTime(endDate), + platformOrderId: orderKeyword || undefined, + }) + + if (response.data.sourceKey && response.data.sourceKey !== sourceKey) { + setSourceKey(response.data.sourceKey) + } + + setPage(response.data.page) + setPageSize(response.data.size) + setTotal(response.data.total) + setItems(response.data.items || []) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '查询发货记录失败') + } finally { + setLoading(false) + } + } + + function resetFilters() { + const nextDateRange = createDefaultDateRange() + setPlatformOrderId('') + setDateRange(nextDateRange) + setPage(1) + setPageSize(DEFAULT_PAGE_SIZE) + void loadRecords(1, { + pageSize: DEFAULT_PAGE_SIZE, + platformOrderId: '', + dateRange: nextDateRange, + }) + } + + async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) { + try { + const dataUrl = await drawRecordImage(record) + setPreviewImageUrl(dataUrl) + setPreviewFileName(buildRecordImageFileName(record)) + setPreviewVisible(true) + } catch (error) { + showError(error instanceof Error ? error.message : '生成发货记录图片失败') + } + } + + function downloadPreviewImage() { + if (!previewImageUrl) { + return + } + + const link = document.createElement('a') + link.href = previewImageUrl + link.download = previewFileName || 'cloudtentacles-delivery-record.png' + link.click() + showSuccess('发货记录图片已生成') + } + + return ( +
+ 共 {total} 条记录} + /> + + + 输入订单号时会先关联本地任务,再匹配 cloudtentacles 发货记录。 + + } + > +
+ setPlatformOrderId(event.target.value)} + onPressEnter={() => loadRecords(1)} + /> + setDateRange(value)} + /> + + + + +
+
+ + {errorMessage ? : null} + + +
+ 发货记录 + + 账号 {currentSourceLabel} · 第 {page} 页,当前展示 {items.length} 条 + +
+ + + rowKey={(row) => row.recordId || `${row.virtualNumberId}-${row.createdAt}`} + loading={loading} + columns={columns} + dataSource={items} + size="small" + scroll={{ x: 1450 }} + pagination={{ + current: page, + pageSize, + total, + pageSizeOptions: PAGE_SIZE_OPTIONS, + showSizeChanger: true, + showTotal: (nextTotal) => `共 ${nextTotal} 条`, + onChange: (nextPage, nextPageSize) => { + void loadRecords(nextPage, { pageSize: nextPageSize }) + }, + }} + locale={{ emptyText: loading ? '发货记录加载中' : '当前筛选条件下暂无发货记录' }} + /> +
+ + setPreviewVisible(false)} + footer={[ + , + , + ]} + > +
+ {previewImageUrl ? ( + + ) : null} +
+
+
+ ) +} + +function getStatusLabel(status: number) { + if (status === 4) return '已完成' + if (status === 1) return '处理中' + if (status === 2) return '待处理' + if (status === 3) return '失败' + return `状态 ${status || '-'}` +} + +function getStatusColor(status: number) { + if (status === 4) return 'green' + if (status === 3) return 'red' + if (status === 1) return 'orange' + return 'blue' +} + +function getLocalDispatchStatusLabel(status: string) { + const normalized = String(status || '').trim() + if (normalized === 'success') return '本地已发货' + if (normalized === 'failed') return '本地发货失败' + if (normalized === 'pending') return '本地待发货' + return normalized || '-' +} + +function getMatchConfidenceLabel(value: string) { + if (value === 'high') return '高' + if (value === 'medium') return '中' + if (value === 'low') return '低' + if (value === 'local') return '本地' + return '' +} + +function createDefaultDateRange(): DateRangeValue { + const end = dayjs() + return [end.subtract(7, 'day').startOf('day'), end] +} + +function formatQueryDateTime(value: Dayjs) { + return value.format('YYYY-MM-DD HH:mm:ss') +} + +async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) { + const productImage = await loadRecordProductImage(record.skuImage) + const canvas = document.createElement('canvas') + const scale = window.devicePixelRatio || 1 + const width = 720 + const height = 330 + canvas.width = width * scale + canvas.height = height * scale + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('当前浏览器不支持图片生成') + } + + ctx.scale(scale, scale) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + ctx.fillStyle = '#1f2937' + ctx.font = '20px sans-serif' + ctx.fillText('订单详情', 28, 38) + ctx.fillStyle = '#9ca3af' + ctx.font = '24px sans-serif' + ctx.fillText('x', width - 40, 38) + + ctx.fillStyle = '#111827' + ctx.font = '13px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88) + ctx.textAlign = 'left' + + drawTicketCard(ctx, record, productImage) + drawRecordMeta(ctx, record) + return canvas.toDataURL('image/png') +} + +function drawTicketCard( + ctx: CanvasRenderingContext2D, + record: AdminCloudtentaclesDeliveryRecordItem, + productImage: HTMLImageElement | null, +) { + const x = 170 + const y = 108 + const width = 392 + const height = 108 + const imageWidth = 106 + + ctx.save() + ctx.beginPath() + ctx.moveTo(x + 18, y) + ctx.lineTo(x + width, y) + ctx.lineTo(x + width, y + height - 22) + ctx.lineTo(x + width - 18, y + height) + ctx.lineTo(x, y + height) + ctx.lineTo(x, y + 18) + ctx.closePath() + ctx.fillStyle = '#f8fbff' + ctx.fill() + ctx.strokeStyle = '#2f80d1' + ctx.lineWidth = 1 + ctx.stroke() + ctx.restore() + + const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height) + gradient.addColorStop(0, '#e7f1ff') + gradient.addColorStop(1, '#b8d7ff') + ctx.fillStyle = gradient + ctx.fillRect(x, y, imageWidth, height) + + if (productImage) { + drawCoverImage(ctx, productImage, x, y, imageWidth, height) + } else { + ctx.fillStyle = '#355d91' + ctx.font = '12px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25) + ctx.fillStyle = '#ffffff' + ctx.fillRect(x + 34, y + 42, 38, 38) + ctx.strokeStyle = '#8ab6ef' + ctx.strokeRect(x + 34, y + 42, 38, 38) + ctx.fillStyle = '#2f80d1' + ctx.font = '20px sans-serif' + ctx.fillText('GP', x + imageWidth / 2, y + 69) + } + ctx.textAlign = 'left' + + const contentX = x + imageWidth + 16 + const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}` + ctx.fillStyle = '#1d5ea8' + ctx.font = '16px sans-serif' + drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170) + ctx.fillStyle = '#3478c6' + ctx.font = '13px sans-serif' + ctx.textAlign = 'right' + ctx.fillText(orderText, x + width - 12, y + 30) + ctx.textAlign = 'left' + + ctx.strokeStyle = '#c8d8ec' + ctx.beginPath() + ctx.moveTo(contentX, y + 42) + ctx.lineTo(x + width - 12, y + 42) + ctx.stroke() + + ctx.fillStyle = '#f59e0b' + ctx.font = '14px sans-serif' + ctx.fillText(`购买机会 x ${record.count || 1}`, contentX, y + 66) + ctx.fillStyle = '#16a34a' + ctx.textAlign = 'right' + ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66) + ctx.textAlign = 'left' + + ctx.fillStyle = '#3478c6' + ctx.font = '14px sans-serif' + drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250) +} + +function drawRecordMeta(ctx: CanvasRenderingContext2D, record: AdminCloudtentaclesDeliveryRecordItem) { + const left = 80 + const top = 250 + const lineHeight = 22 + const values = [ + `订单店铺:${record.orderShopName || record.orderShopId || '-'}`, + `核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`, + `绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`, + ] + + ctx.fillStyle = '#374151' + ctx.font = '13px sans-serif' + values.forEach((value, index) => { + drawEllipsisText(ctx, value, left, top + index * lineHeight, 560) + }) +} + +async function loadRecordProductImage(imageUrl: string) { + const normalizedUrl = String(imageUrl || '').trim() + if (!normalizedUrl) { + return null + } + + const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent( + normalizedUrl, + )}` + return loadProtectedImage(proxyUrl).catch(() => null) +} + +async function loadProtectedImage(src: string) { + const token = getAdminToken() + const response = await fetch(src, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + + if (!response.ok) { + throw new Error('商品图片加载失败') + } + + const objectUrl = URL.createObjectURL(await response.blob()) + try { + return await loadImage(objectUrl) + } finally { + URL.revokeObjectURL(objectUrl) + } +} + +function loadImage(src: string) { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('商品图片加载失败')) + image.src = src + }) +} + +function drawCoverImage( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number, +) { + const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight) + const drawWidth = image.naturalWidth * ratio + const drawHeight = image.naturalHeight * ratio + const drawX = x + (width - drawWidth) / 2 + const drawY = y + (height - drawHeight) / 2 + + ctx.save() + ctx.beginPath() + ctx.rect(x, y, width, height) + ctx.clip() + ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight) + ctx.restore() +} + +function drawEllipsisText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + maxWidth: number, +) { + let output = text + while (output.length > 0 && ctx.measureText(output).width > maxWidth) { + output = `${output.slice(0, -2)}...` + } + ctx.fillText(output || '-', x, y) +} + +function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) { + const name = sanitizeFileName(record.name || '发货记录') + const id = sanitizeFileName( + record.platformOrderId || + record.recordId.slice(0, 8) || + String(record.virtualNumberId || 'record'), + ) + return `${name}-${id}.png` +} + +function sanitizeFileName(value: string) { + return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48) +} diff --git a/apps/frontend-react/src/styles/main.css b/apps/frontend-react/src/styles/main.css index 4351b551..fe5396e7 100644 --- a/apps/frontend-react/src/styles/main.css +++ b/apps/frontend-react/src/styles/main.css @@ -729,6 +729,65 @@ select { color: #6b7280; } +.total-badge { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border-radius: 6px; + background: #eef6ff; + color: #1677ff; + font-weight: 700; +} + +.cloud-records-filter { + display: grid; + grid-template-columns: minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) auto; + gap: 12px; + align-items: center; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.table-toolbar span { + color: #6b7280; + font-size: 13px; +} + +.status-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.record-preview { + display: grid; + place-items: center; + min-height: 260px; + padding: 12px; + background: #f8fafc; + border: 1px solid #edf0f5; + border-radius: 6px; +} + +.record-preview .ant-image, +.record-preview img { + max-width: 100%; +} + +.record-preview img { + height: auto; + border: 1px solid #edf0f5; + background: #ffffff; +} + @media (max-width: 900px) { .admin-sider { position: fixed !important; @@ -771,6 +830,14 @@ select { .platform-active-summary .ant-card-body { display: grid; } + + .cloud-records-filter { + grid-template-columns: 1fr; + } + + .table-toolbar { + display: grid; + } } @media (max-width: 720px) {