Files
hfb_sys/frontend/src/features/admin/views/AdminListingsView.vue
T
2026-06-09 19:04:11 +08:00

594 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import {
fetchAdminListings,
type AdminListingPage,
type AdminListingQuery,
type Listing,
} from '@/features/listings'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { formatCentWithSymbol } from '@/shared/utils/money'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getResourceQuantity,
getSkinGroup,
} from '@/utils/listingDisplay'
const filters = reactive<AdminListingQuery>({
owner_id: '',
status: '',
review_status: '',
})
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) => rentalPeriodText(row),
},
] as const
async function queryListings() {
currentPage.value = 1
await loadListings()
}
function resetFilters() {
filters.owner_id = ''
filters.status = ''
filters.review_status = ''
void queryListings()
}
function setStatusFilter(status: string) {
filters.status = (filters.status === status ? '' : status) as AdminListingQuery['status']
filters.review_status = ''
void queryListings()
}
function setReviewStatusFilter(reviewStatus: string) {
filters.review_status = (
filters.review_status === reviewStatus ? '' : reviewStatus
) as AdminListingQuery['review_status']
filters.status = ''
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) {
return formatCentWithSymbol(row.price_cent)
}
function listingCoinM(row: Listing) {
return formatHafCoinM(getCoinWan(row))
}
function assetText(row: Listing, key: string) {
const value = row.asset_summary?.[key]
if (typeof value === 'number') return formatQuantity(value)
if (typeof value === 'string' && value.trim()) return value.trim()
return '-'
}
function levelText(row: Listing, key: string) {
const value = assetText(row, key)
return value === '-' ? value : value.replace(/级$/, '')
}
function regionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function resourceText(row: Listing, key: string) {
const quantity = getResourceQuantity(row, key)
return quantity > 0 ? formatQuantity(quantity) : '-'
}
function skinGroupText(row: Listing, groupKey: string) {
const skins = getSkinGroup(row, groupKey)
return skins.length ? skins.join('、') : '-'
}
function characterAndWeaponSkinText(row: Listing) {
const groups = [
{ label: '红皮', key: 'operatorRed' },
{ label: '金皮', key: 'operatorGold' },
{ label: '武器', key: 'weapon' },
]
const parts = groups
.map(group => {
const names = getSkinGroup(row, group.key)
return names.length ? `${group.label}${names.join('、')}` : ''
})
.filter(Boolean)
return parts.length ? parts.join(' / ') : '-'
}
function rentAndDepositText(row: Listing) {
return `${listingPrice(row)}/${formatCentWithSymbol(row.deposit_amount_cent)}`
}
function estimateTitle(row: Listing) {
const dailyLoss = getDailyLoss(row)
return dailyLoss ? `按哈夫币 ${listingCoinM(row)}、日耗 ${dailyLoss} 估算` : '未配置日耗'
}
function rentalPeriodText(row: Listing) {
const dailyLoss = getDailyLoss(row)
return [formatEstimatedRentalDuration(row), dailyLoss ? `日耗 ${dailyLoss}` : '']
.filter(Boolean)
.join('\n')
}
function formatQuantity(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
</script>
<template>
<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>
<div class="listing-control-panel">
<!-- 统计指标行 -->
<div class="listing-stats-row">
<div
class="stat-card clickable"
:class="{ active: !filters.status && !filters.review_status }"
@click="resetFilters"
>
<span class="stat-label">全部商品</span>
<strong class="stat-value">{{ totalListings }}</strong>
</div>
<div
class="stat-card clickable"
:class="{ active: filters.status === 'published' }"
@click="setStatusFilter('published')"
>
<span class="stat-label">已上架</span>
<strong class="stat-value">{{ publishedCount }}</strong>
</div>
<div
class="stat-card clickable"
:class="{ active: filters.status === 'rented' }"
@click="setStatusFilter('rented')"
>
<span class="stat-label">已锁定</span>
<strong class="stat-value">{{ rentedCount }}</strong>
</div>
<div
class="stat-card clickable"
:class="{ active: filters.review_status === 'pending' }"
@click="setReviewStatusFilter('pending')"
>
<span class="stat-label">待审核</span>
<strong class="stat-value">{{ pendingCount }}</strong>
</div>
</div>
<!-- 筛选和操作行 -->
<div class="listing-filter-row">
<el-form class="listing-filter-bar" inline>
<el-form-item label="号主 ID">
<el-input
v-model="filters.owner_id"
clearable
placeholder="筛选号主"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="商品状态">
<el-select
v-model="filters.status"
clearable
placeholder="全部状态"
style="width: 140px"
>
<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="全部审核"
style="width: 140px"
>
<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="queryListings"
>查询</el-button
>
<el-button :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot"
>下载截图</el-button
>
</div>
</div>
</div>
<el-table
v-loading="loading"
class="table-panel admin-listings-table"
:data="listings"
row-key="id"
stripe
empty-text="暂无商品"
>
<el-table-column label="商品编码" width="88">
<template #default="{ row }">
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
</template>
</el-table-column>
<el-table-column label="地区" width="86">
<template #default="{ row }">{{ regionText(row) }}</template>
</el-table-column>
<el-table-column prop="login_platform" label="上号方式" width="78" />
<el-table-column label="哈夫币(M)" width="82">
<template #default="{ row }">{{ listingCoinM(row) }}</template>
</el-table-column>
<el-table-column label="保险" width="54">
<template #default="{ row }">{{ assetText(row, 'season_insurance') }}</template>
</el-table-column>
<el-table-column label="体力(级)" width="68">
<template #default="{ row }">{{ levelText(row, 'stamina_level') }}</template>
</el-table-column>
<el-table-column label="负重(级)" width="68">
<template #default="{ row }">{{ levelText(row, 'load_level') }}</template>
</el-table-column>
<el-table-column label="账号等级" width="74">
<template #default="{ row }">{{ assetText(row, 'fire_level') }}</template>
</el-table-column>
<el-table-column label="绝密KD" width="66">
<template #default="{ row }">{{ assetText(row, 'secret_kd') }}</template>
</el-table-column>
<el-table-column label="awm子弹" width="72">
<template #default="{ row }">{{ resourceText(row, 'awmAmmo') }}</template>
</el-table-column>
<el-table-column label="六头" width="48">
<template #default="{ row }">{{ resourceText(row, 'helmet6') }}</template>
</el-table-column>
<el-table-column label="六甲" width="48">
<template #default="{ row }">{{ resourceText(row, 'armor6') }}</template>
</el-table-column>
<el-table-column label="特殊刀皮" width="104">
<template #default="{ row }">
{{ skinGroupText(row, 'melee') }}
</template>
</el-table-column>
<el-table-column label="人物红皮/人物金皮/武器皮肤" min-width="226">
<template #default="{ row }">
{{ characterAndWeaponSkinText(row) }}
</template>
</el-table-column>
<el-table-column label="租金/押金" width="90">
<template #default="{ row }">{{ rentAndDepositText(row) }}</template>
</el-table-column>
<el-table-column label="比例" width="62">
<template #default="{ row }">{{ formatRatio(row) }}</template>
</el-table-column>
<el-table-column label="租期" width="82">
<template #default="{ row }">
<span :title="estimateTitle(row)">{{ formatEstimatedRentalDuration(row) }}</span>
<span v-if="getDailyLoss(row)" class="table-subtext">日耗 {{ getDailyLoss(row) }}</span>
</template>
</el-table-column>
<el-table-column label="详情" width="60">
<template #default="{ row }">
<RouterLink :to="`/admin/listings/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</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>