商品管理ui优化, 后台支持分页参数

This commit is contained in:
yml
2026-06-01 00:56:45 +08:00
parent 4437c3b439
commit aff108fc96
7 changed files with 503 additions and 73 deletions
+280 -56
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import { fetchAdminListings, type AdminListingQuery, type Listing } from '@/api/listings'
import { fetchAdminListings, type AdminListingPage, type AdminListingQuery, type Listing } from '@/api/listings'
import { useAdminTable } from '@/composables/useAdminTable'
import { useMoney } from '@/composables/useMoney'
import {
@@ -23,24 +24,237 @@ const filters = reactive<AdminListingQuery>({
owner_id: '',
status: '',
review_status: '',
limit: 200,
})
const { loading, data: listings, load: loadListings } = useAdminTable<Listing[]>({
fetchFn: () => fetchAdminListings(filters),
initialData: [],
const pageSize = ref(10)
const currentPage = ref(1)
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
fetchFn: () =>
fetchAdminListings({
...filters,
page: currentPage.value,
page_size: pageSize.value,
}),
initialData: {
items: [],
total: 0,
page: 1,
page_size: 10,
},
})
const listings = computed(() => listingPage.value.items)
const totalListings = computed(() => listingPage.value.total)
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
const rentedCount = computed(() => listings.value.filter((item) => item.status === 'rented').length)
const pendingCount = computed(() => listings.value.filter((item) => item.review_status === 'pending').length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
const screenshotColumns = [
{ label: '商品编码', width: 88, read: (row: Listing) => formatListingCode(row) },
{ label: '地区', width: 92, read: (row: Listing) => regionText(row) },
{ label: '上号方式', width: 78, read: (row: Listing) => row.login_platform || '-' },
{ label: '哈夫币(M)', width: 86, read: (row: Listing) => listingCoinM(row) },
{ label: '保险', width: 56, read: (row: Listing) => assetText(row, 'season_insurance') },
{ label: '体力(级)', width: 70, read: (row: Listing) => levelText(row, 'stamina_level') },
{ label: '负重(级)', width: 70, read: (row: Listing) => levelText(row, 'load_level') },
{ label: '账号等级', width: 76, read: (row: Listing) => assetText(row, 'fire_level') },
{ label: '绝密KD', width: 66, read: (row: Listing) => assetText(row, 'secret_kd') },
{ label: 'awm子弹', width: 74, read: (row: Listing) => resourceText(row, 'awmAmmo') },
{ label: '六头', width: 50, read: (row: Listing) => resourceText(row, 'helmet6') },
{ label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') },
{ label: '特殊刀皮', width: 110, read: (row: Listing) => skinGroupText(row, 'melee') },
{ label: '人物红皮/人物金皮/武器皮肤', width: 420, read: (row: Listing) => characterAndWeaponSkinText(row) },
{ label: '租金/押金', width: 96, read: (row: Listing) => rentAndDepositText(row) },
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(row) },
{ label: '租期', width: 92, read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}` },
] as const
async function queryListings() {
currentPage.value = 1
await loadListings()
}
function resetFilters() {
filters.owner_id = ''
filters.status = ''
filters.review_status = ''
filters.limit = 200
void loadListings()
void queryListings()
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
await loadListings()
}
async function prevPage() {
currentPage.value = Math.max(1, currentPage.value - 1)
await loadListings()
}
async function nextPage() {
currentPage.value = Math.min(totalPages.value, currentPage.value + 1)
await loadListings()
}
async function copyTableScreenshot() {
try {
const blob = await createTableScreenshotBlob()
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
ElMessage.warning('当前浏览器不支持直接复制图片,请使用下载截图')
return
}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
ElMessage.success('表格截图已复制到剪贴板')
} catch (error) {
ElMessage.error(readScreenshotError(error, '复制截图失败'))
}
}
async function downloadTableScreenshot() {
try {
const blob = await createTableScreenshotBlob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `商品管理表格-${formatFileTime(new Date())}.png`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('表格截图已下载')
} catch (error) {
ElMessage.error(readScreenshotError(error, '下载截图失败'))
}
}
async function createTableScreenshotBlob() {
if (!listings.value.length) throw new Error('当前页暂无可截图数据')
const canvas = renderTableScreenshotCanvas(listings.value)
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('截图生成失败')
return blob
}
function renderTableScreenshotCanvas(rows: Listing[]) {
const ratio = window.devicePixelRatio || 1
const paddingX = 24
const paddingY = 20
const titleHeight = 44
const headerHeight = 34
const lineHeight = 18
const cellPaddingX = 8
const cellPaddingY = 10
const tableWidth = screenshotColumns.reduce((sum, column) => sum + column.width, 0)
const canvasWidth = tableWidth + paddingX * 2
const measureCanvas = document.createElement('canvas')
const measureCtx = measureCanvas.getContext('2d')
if (!measureCtx) throw new Error('截图画布初始化失败')
measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif'
const rowLines = rows.map((row) =>
screenshotColumns.map((column) => wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)),
)
const rowHeights = rowLines.map((lineGroups) => {
const maxLines = Math.max(...lineGroups.map((lines) => lines.length), 1)
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2)
})
const canvasHeight = paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
const canvas = document.createElement('canvas')
canvas.width = Math.round(canvasWidth * ratio)
canvas.height = Math.round(canvasHeight * ratio)
canvas.style.width = `${canvasWidth}px`
canvas.style.height = `${canvasHeight}px`
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('截图画布初始化失败')
ctx.scale(ratio, ratio)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
ctx.fillStyle = '#1b2559'
ctx.font = '700 18px Arial, "Microsoft YaHei", sans-serif'
ctx.fillText('商品管理', paddingX, paddingY + 20)
ctx.fillStyle = '#8f9bba'
ctx.font = '12px Arial, "Microsoft YaHei", sans-serif'
ctx.fillText(`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value}`, paddingX, paddingY + 40)
let y = paddingY + titleHeight
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
ctx.font = '700 12px Arial, "Microsoft YaHei", sans-serif'
ctx.fillStyle = '#8f9bba'
let x = paddingX
for (const column of screenshotColumns) {
drawCellText(ctx, [column.label], x + cellPaddingX, y + 22, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + headerHeight, paddingX + tableWidth, y + headerHeight)
y += headerHeight
ctx.font = '13px Arial, "Microsoft YaHei", sans-serif'
rows.forEach((row, rowIndex) => {
const rowHeight = rowHeights[rowIndex] ?? 44
drawRect(ctx, paddingX, y, tableWidth, rowHeight, rowIndex % 2 === 0 ? '#ffffff' : '#fbfcff')
x = paddingX
for (const [columnIndex, column] of screenshotColumns.entries()) {
const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-']
ctx.fillStyle = columnIndex === 0 ? '#4b5563' : '#3f4654'
ctx.font = columnIndex === 0 ? '700 13px Arial, "Microsoft YaHei", sans-serif' : '13px Arial, "Microsoft YaHei", sans-serif'
drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + rowHeight, paddingX + tableWidth, y + rowHeight)
y += rowHeight
})
return canvas
}
function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const paragraphs = String(text || '-').split('\n')
const lines: string[] = []
for (const paragraph of paragraphs) {
let line = ''
for (const char of paragraph) {
const nextLine = line + char
if (line && ctx.measureText(nextLine).width > maxWidth) {
lines.push(line)
line = char
} else {
line = nextLine
}
}
lines.push(line || '-')
}
return lines
}
function drawCellText(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number) {
lines.forEach((line, index) => {
ctx.fillText(line, x, y + index * lineHeight)
})
}
function drawRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, color: string) {
ctx.fillStyle = color
ctx.fillRect(x, y, width, height)
}
function drawLine(ctx: CanvasRenderingContext2D, startX: number, startY: number, endX: number, endY: number) {
ctx.strokeStyle = '#e6eaf2'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.stroke()
}
function formatFileTime(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())}`
}
function readScreenshotError(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback
}
function listingPrice(row: Listing) {
@@ -108,64 +322,53 @@ function formatQuantity(value: number) {
</script>
<template>
<section class="page">
<section class="page admin-listings-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listings</p>
<h1>商品管理</h1>
<p>查看商品编码地区上号方式账号资产租金押金比例和预计租期</p>
</div>
<div class="toolbar-actions">
</div>
<div class="listing-control-panel">
<div class="listing-metric-strip">
<span>结果 <strong>{{ totalListings }}</strong></span>
<span>本页 <strong>{{ listings.length }}</strong></span>
<span>已上架 <strong>{{ publishedCount }}</strong></span>
<span>已锁定 <strong>{{ rentedCount }}</strong></span>
<span>待审核 <strong>{{ pendingCount }}</strong></span>
</div>
<el-form class="listing-filter-bar" label-position="top">
<el-form-item label="号主 ID">
<el-input v-model="filters.owner_id" clearable placeholder="按号主筛选" />
</el-form-item>
<el-form-item label="商品状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="草稿" value="draft" />
<el-option label="已上架" value="published" />
<el-option label="已锁定" value="rented" />
<el-option label="已下架" value="offline" />
<el-option label="异常" value="abnormal" />
</el-select>
</el-form-item>
<el-form-item label="审核状态">
<el-select v-model="filters.review_status" clearable placeholder="全部审核状态" class="full-control">
<el-option label="未提交" value="none" />
<el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</el-form-item>
</el-form>
<div class="listing-action-strip">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadListings">查询</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings">查询</el-button>
<el-button :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>当前结果</span>
<strong>{{ listings.length }} </strong>
</div>
<div class="metric-card">
<span>已上架</span>
<strong>{{ publishedCount }} </strong>
</div>
<div class="metric-card">
<span>已锁定</span>
<strong>{{ rentedCount }} </strong>
</div>
<div class="metric-card">
<span>待审核</span>
<strong>{{ pendingCount }} </strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="号主 ID">
<el-input v-model="filters.owner_id" clearable placeholder="按号主筛选" />
</el-form-item>
<el-form-item label="商品状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="草稿" value="draft" />
<el-option label="已上架" value="published" />
<el-option label="已锁定" value="rented" />
<el-option label="已下架" value="offline" />
<el-option label="异常" value="abnormal" />
</el-select>
</el-form-item>
<el-form-item label="审核状态">
<el-select v-model="filters.review_status" clearable placeholder="全部审核状态" class="full-control">
<el-option label="未提交" value="none" />
<el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</el-form-item>
<el-form-item label="查询条数">
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
</el-form-item>
</el-form>
<el-table
v-loading="loading"
class="table-panel admin-listings-table"
@@ -240,5 +443,26 @@ function formatQuantity(value: number) {
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<span class="pagination-summary">当前页 {{ listings.length }} {{ totalListings }} </span>
<div class="pagination-controls">
<span class="pagination-size-label">每页</span>
<el-select
:model-value="pageSize"
class="page-size-select"
size="small"
@update:model-value="handlePageSizeChange"
>
<el-option :value="10" label="10条" />
<el-option :value="20" label="20条" />
<el-option :value="50" label="50条" />
<el-option :value="100" label="100条" />
</el-select>
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span>
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下页</el-button>
</div>
</div>
</section>
</template>