增加发货记录查询功能-1
This commit is contained in:
+561
@@ -0,0 +1,561 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
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
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const sourceLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const sourceKey = ref('')
|
||||
const dateRange = ref<[string, string]>(createDefaultDateRange())
|
||||
const page = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const total = ref(0)
|
||||
const items = ref<AdminCloudtentaclesDeliveryRecordItem[]>([])
|
||||
const sourceOptions = ref<RecordSourceOption[]>([])
|
||||
const previewVisible = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
const previewFileName = ref('')
|
||||
|
||||
const usableSourceOptions = computed(() =>
|
||||
sourceOptions.value.filter((source) => source.enabled && source.hasToken),
|
||||
)
|
||||
|
||||
const currentSourceLabel = computed(() => {
|
||||
const matched = sourceOptions.value.find((source) => source.key === sourceKey.value)
|
||||
return matched?.label || sourceKey.value || '-'
|
||||
})
|
||||
|
||||
async function loadSources() {
|
||||
sourceLoading.value = true
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesRecordSources()
|
||||
sourceOptions.value = response.data.sources || []
|
||||
if (!sourceKey.value) {
|
||||
sourceKey.value = usableSourceOptions.value[0]?.key || sourceOptions.value[0]?.key || ''
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 账号失败'
|
||||
} finally {
|
||||
sourceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecords(nextPage = page.value) {
|
||||
if (!sourceKey.value) {
|
||||
showError('请先选择 cloudtentacles 账号')
|
||||
return
|
||||
}
|
||||
|
||||
const [startDate, endDate] = dateRange.value || []
|
||||
if (!startDate || !endDate) {
|
||||
showError('请选择查询时间范围')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesDeliveryRecords({
|
||||
sourceKey: sourceKey.value,
|
||||
page: nextPage,
|
||||
size: pageSize.value,
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
page.value = response.data.page
|
||||
pageSize.value = response.data.size
|
||||
total.value = response.data.total
|
||||
items.value = response.data.items || []
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '查询发货记录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
dateRange.value = createDefaultDateRange()
|
||||
page.value = 1
|
||||
pageSize.value = 100
|
||||
void loadRecords(1)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
void loadRecords(nextPage)
|
||||
}
|
||||
|
||||
function handlePageSizeChange(nextSize: number) {
|
||||
pageSize.value = nextSize
|
||||
void loadRecords(1)
|
||||
}
|
||||
|
||||
async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
try {
|
||||
const dataUrl = await drawRecordImage(record)
|
||||
previewImageUrl.value = dataUrl
|
||||
previewFileName.value = buildRecordImageFileName(record)
|
||||
previewVisible.value = true
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '生成发货记录图片失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPreviewImage() {
|
||||
if (!previewImageUrl.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = previewImageUrl.value
|
||||
link.download = previewFileName.value || 'cloudtentacles-delivery-record.png'
|
||||
link.click()
|
||||
showSuccess('发货记录图片已生成')
|
||||
}
|
||||
|
||||
function getStatusLabel(status: number) {
|
||||
if (status === 4) return '已完成'
|
||||
if (status === 1) return '处理中'
|
||||
if (status === 2) return '待处理'
|
||||
if (status === 3) return '失败'
|
||||
return `状态 ${status || '-'}`
|
||||
}
|
||||
|
||||
function getStatusType(status: number) {
|
||||
if (status === 4) return 'success'
|
||||
if (status === 3) return 'danger'
|
||||
if (status === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function createDefaultDateRange(): [string, string] {
|
||||
const end = new Date()
|
||||
const start = new Date(end)
|
||||
start.setDate(start.getDate() - 7)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [formatLocalDateTime(start), formatLocalDateTime(end)]
|
||||
}
|
||||
|
||||
function formatLocalDateTime(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||
date.getHours(),
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
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 = 260
|
||||
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('×', 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)
|
||||
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.virtualNumberId || record.recordId.slice(-6) || '-'}`
|
||||
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(`购买机会 × ${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)
|
||||
}
|
||||
|
||||
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.recordId.slice(0, 8) || String(record.virtualNumberId || 'record'))
|
||||
return `${name}-${id}.png`
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSources()
|
||||
if (sourceKey.value) {
|
||||
await loadRecords(1)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cloud-records-page list-page">
|
||||
<AdminPageHeader title="查询发货记录" description="按 cloudtentacles 账号和时间范围查询平台发货记录。">
|
||||
<template #extra>
|
||||
<span class="total-badge">共 {{ total }} 条记录</span>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">查询条件</span>
|
||||
<span class="card-desc">查询 cloudtentacles 的已发货记录,记录码默认使用平台发货记录。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filter-grid cloud-records-filter">
|
||||
<el-select
|
||||
v-model="sourceKey"
|
||||
class="filter-control"
|
||||
placeholder="cloudtentacles 账号"
|
||||
:loading="sourceLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="source in sourceOptions"
|
||||
:key="source.key"
|
||||
:label="`${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`"
|
||||
:value="source.key"
|
||||
:disabled="!source.enabled || !source.hasToken"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
class="filter-control cloud-records-date-range"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<div class="filter-buttons">
|
||||
<el-button round @click="resetFilters">重置</el-button>
|
||||
<el-button round type="primary" :loading="loading" @click="loadRecords(1)">
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="section-card"
|
||||
v-loading="loading"
|
||||
element-loading-text="发货记录加载中"
|
||||
>
|
||||
<template v-if="items.length === 0 && !loading">
|
||||
<el-empty description="当前筛选条件下暂无发货记录" :image-size="60" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-toolbar">
|
||||
<strong>发货记录</strong>
|
||||
<span>账号 {{ currentSourceLabel }} · 第 {{ page }} 页,当前展示 {{ items.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" stripe size="small" class="data-table cloud-records-table">
|
||||
<el-table-column label="商品 / 角色" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="row.name">{{ row.name || '-' }}</span>
|
||||
<span class="cell-subline" :title="row.fulfillUser">
|
||||
账号:{{ row.fulfillUser || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟号" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title">{{ row.phone || '-' }}</span>
|
||||
<span class="cell-subline">VN {{ row.virtualNumberId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" effect="plain">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="120">
|
||||
<template #default="{ row }">{{ row.operatorName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatAdminDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="记录 ID" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-subline" :title="row.recordId">{{ row.recordId || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="136" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" plain @click="generateRecordImage(row)">
|
||||
生成图片
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="cloud-records-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[100, 200, 500, 1000]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" title="发货记录图片" width="760px" destroy-on-close>
|
||||
<div class="record-preview">
|
||||
<img v-if="previewImageUrl" :src="previewImageUrl" alt="发货记录图片预览" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="downloadPreviewImage">下载图片</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/styles/admin-list-pages.css';
|
||||
|
||||
.cloud-records-filter {
|
||||
grid-template-columns: minmax(180px, 0.34fr) minmax(360px, 1fr) auto;
|
||||
}
|
||||
|
||||
.cloud-records-date-range {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cloud-records-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.record-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
padding: var(--space-3);
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.record-preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cloud-records-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cloud-records-pagination {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Expand,
|
||||
Fold,
|
||||
List,
|
||||
Memo,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Tickets,
|
||||
@@ -80,6 +81,7 @@ const navItems = computed(() => {
|
||||
items.push(
|
||||
{ to: '/admin/orders', label: '订单', icon: Document },
|
||||
{ to: '/admin/tasks', label: '任务', icon: List },
|
||||
{ to: '/admin/cloudtentacles-records', label: '查询发货记录', icon: Memo },
|
||||
)
|
||||
|
||||
if (isAdmin.value) {
|
||||
|
||||
Reference in New Issue
Block a user