迁移发货记录页面
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
|
||||
<Route path="tasks" element={<AdminTasksPage />} />
|
||||
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
||||
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
|
||||
<Route element={<RequireRole roles={['admin']} />}>
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
|
||||
|
||||
@@ -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<DateRangeValue>(createDefaultDateRange())
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [items, setItems] = useState<AdminCloudtentaclesDeliveryRecordItem[]>([])
|
||||
const [sourceOptions, setSourceOptions] = useState<RecordSourceOption[]>([])
|
||||
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<AdminCloudtentaclesDeliveryRecordItem> = [
|
||||
{
|
||||
title: '商品 / 角色',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.name || '-' }}>
|
||||
{row.name || '-'}
|
||||
</Typography.Text>
|
||||
<span className="muted">账号:{row.fulfillUser || '-'}</span>
|
||||
{row.roleName || row.roleId ? (
|
||||
<span className="muted">
|
||||
角色:{row.roleName || '-'}
|
||||
{row.roleId ? `(${row.roleId})` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单 / 店铺',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.platformOrderId || '-' }}>
|
||||
{row.platformOrderId || '-'}
|
||||
</Typography.Text>
|
||||
<span className="muted">订单店铺:{row.orderShopName || row.orderShopId || '-'}</span>
|
||||
<span className="muted">核销店铺:{row.consumeShopName || row.consumeShopId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '虚拟号',
|
||||
minWidth: 160,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.phone || '-'}</span>
|
||||
<span className="muted">VN {row.virtualNumberId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<div className="status-stack">
|
||||
<Tag color={getStatusColor(row.status)}>{getStatusLabel(row.status)}</Tag>
|
||||
{row.localDispatchStatus ? (
|
||||
<span className="muted">{getLocalDispatchStatusLabel(row.localDispatchStatus)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
minWidth: 120,
|
||||
render: (_, row) => row.operatorName || '-',
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
minWidth: 170,
|
||||
render: (_, row) => formatAdminDateTime(row.createdAt),
|
||||
},
|
||||
{
|
||||
title: '记录 ID',
|
||||
minWidth: 230,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: row.recordId || '-' }}>
|
||||
{row.recordId || '-'}
|
||||
</Typography.Text>
|
||||
{row.taskNo ? (
|
||||
<span className="muted">
|
||||
任务:{row.taskNo}
|
||||
{getMatchConfidenceLabel(row.matchConfidence)
|
||||
? ` · 匹配${getMatchConfidenceLabel(row.matchConfidence)}`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right',
|
||||
width: 136,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<FileImageOutlined />}
|
||||
onClick={() => generateRecordImage(row)}
|
||||
>
|
||||
生成图片
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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 (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="查询发货记录"
|
||||
description="按订单号、cloudtentacles 账号和时间范围查询平台发货记录。"
|
||||
extra={<span className="total-badge">共 {total} 条记录</span>}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="查询条件"
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
输入订单号时会先关联本地任务,再匹配 cloudtentacles 发货记录。
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<div className="cloud-records-filter">
|
||||
<Select
|
||||
value={sourceKey || undefined}
|
||||
loading={sourceLoading}
|
||||
placeholder="cloudtentacles 账号"
|
||||
options={sourceOptions.map((source) => ({
|
||||
label: `${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`,
|
||||
value: source.key,
|
||||
disabled: !source.enabled || !source.hasToken,
|
||||
}))}
|
||||
onChange={setSourceKey}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={platformOrderId}
|
||||
placeholder="订单号"
|
||||
onChange={(event) => setPlatformOrderId(event.target.value)}
|
||||
onPressEnter={() => loadRecords(1)}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
value={dateRange}
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
className="full-width"
|
||||
onChange={(value) => setDateRange(value)}
|
||||
/>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={() => loadRecords(1)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<Card>
|
||||
<div className="table-toolbar">
|
||||
<strong>发货记录</strong>
|
||||
<span>
|
||||
账号 {currentSourceLabel} · 第 {page} 页,当前展示 {items.length} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table<AdminCloudtentaclesDeliveryRecordItem>
|
||||
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 ? '发货记录加载中' : '当前筛选条件下暂无发货记录' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="发货记录图片"
|
||||
open={previewVisible}
|
||||
width={780}
|
||||
destroyOnHidden
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadPreviewImage}
|
||||
>
|
||||
下载图片
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="record-preview">
|
||||
{previewImageUrl ? (
|
||||
<AntdImage src={previewImageUrl} alt="发货记录图片预览" preview={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLImageElement>((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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user