@@ -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 ( 180 px , 0.34 fr ) minmax ( 360 px , 1 fr ) 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 : 260 px ;
padding : var ( -- space - 3 ) ;
background : # f8fafc ;
border : 1 px solid var ( -- border - default ) ;
border - radius : var ( -- radius - md ) ;
}
. record - preview img {
max - width : 100 % ;
height : auto ;
border : 1 px solid var ( -- border - default ) ;
background : # fff ;
}
@ media ( max - width : 900 px ) {
. cloud - records - filter {
grid - template - columns : 1 fr ;
}
. cloud - records - pagination {
justify - content : flex - start ;
}
}
< / style >