= [
+ {
+ 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) => (
+ }
+ onClick={() => generateRecordImage(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 发货记录。
+
+ }
+ >
+
+
+
+
+ {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={[
+ ,
+ }
+ onClick={downloadPreviewImage}
+ >
+ 下载图片
+ ,
+ ]}
+ >
+
+ {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) {