feat(listing): 优化商品管理表格列布局

This commit is contained in:
yml
2026-11-12 15:17:27 +08:00
parent e8a281fce6
commit 7b2566ad21
2 changed files with 242 additions and 107 deletions
@@ -55,8 +55,28 @@ const fallbackLevelOptions = ['1级', '2级', '3级', '4级', '5级', '6级', '7
const pageSize = ref(10)
const currentPage = ref(1)
const pageRef = ref<HTMLElement | null>(null)
const listingTableWidth = ref(1296)
const activeFilterPopover = ref<FilterPopover | ''>('')
const publishOptions = ref(emptyListingPublishOptions)
const listingColumnUnits = {
code: 70,
zone: 75,
region: 105,
core: 85,
level: 60,
equipment: 80,
skin: 145,
face: 60,
remark: 85,
price: 100,
period: 55,
ratio: 35,
detail: 45,
} as const
type ListingColumnKey = keyof typeof listingColumnUnits
const listingColumnWidths = computed(() => allocateListingColumnWidths(listingTableWidth.value))
let listingResizeObserver: ResizeObserver | undefined
const {
loading,
@@ -133,35 +153,39 @@ const skinFilterLabel = computed(() => {
})
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: 98, read: (row: Listing) => formatListingCode(row) },
{
label: '人物红皮/人物金皮/武器皮肤',
width: 420,
read: (row: Listing) => characterAndWeaponSkinText(row),
label: '专区/赔付',
width: 105,
read: (row: Listing) => zoneCompensationText(row),
},
{ label: '人脸', width: 60, read: (row: Listing) => faceOwnerText(row) },
{ label: '全英雄', width: 74, read: (row: Listing) => allHeroText(row) },
{ label: '号主备注', width: 160, read: (row: Listing) => ownerRemarkText(row) },
{ label: '租客纯币价格', width: 105, read: (row: Listing) => renterPureCoinPriceText(row) },
{
label: '租期',
width: 92,
read: (row: Listing) => rentalPeriodText(row),
label: '地区/上号/纯币',
width: 147,
read: (row: Listing) => regionLoginCoinText(row),
},
{ label: '租金/押金', width: 96, read: (row: Listing) => rentAndDepositText(row) },
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(row) },
{
label: '体力/负重/保险',
width: 119,
read: (row: Listing) => coreAssetText(row),
},
{ label: '等级/KD', width: 84, read: (row: Listing) => levelKdText(row) },
{
label: 'AWM/六头/六甲',
width: 112,
read: (row: Listing) => equipmentText(row),
},
{ label: '皮肤资产', width: 203, read: (row: Listing) => skinAssetText(row) },
{ label: '人脸/全英雄', width: 84, read: (row: Listing) => faceHeroText(row) },
{ label: '号主备注', width: 119, read: (row: Listing) => ownerRemarkText(row) },
{
label: '纯币/租金/押金',
width: 140,
read: (row: Listing) => priceRentDepositText(row),
},
{ label: '租期', width: 77, read: (row: Listing) => rentalPeriodText(row) },
{ label: '比例', width: 49, read: (row: Listing) => formatRatio(row) },
{ label: '详情', width: 63, read: () => '详情' },
] as const
async function queryListings() {
@@ -273,6 +297,7 @@ watch(
)
onMounted(async () => {
observeListingTableWidth()
restoreListState()
void loadPublishOptions()
await loadListings()
@@ -282,11 +307,38 @@ onMounted(async () => {
onBeforeUnmount(() => {
saveListState()
listingResizeObserver?.disconnect()
adminMainEl()?.removeEventListener('scroll', handleAdminMainScroll)
if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer)
if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer)
})
function observeListingTableWidth() {
const element = pageRef.value
if (!element) return
listingTableWidth.value = Math.max(element.clientWidth, 960)
listingResizeObserver = new ResizeObserver(entries => {
const width = entries[0]?.contentRect.width || element.clientWidth
listingTableWidth.value = Math.max(Math.round(width), 960)
})
listingResizeObserver.observe(element)
}
function allocateListingColumnWidths(totalWidth: number) {
const width = Math.max(Math.round(totalWidth), 960)
const keys = Object.keys(listingColumnUnits) as ListingColumnKey[]
const widths = {} as Record<ListingColumnKey, number>
let usedWidth = 0
for (const key of keys) {
if (key === 'detail') continue
const columnWidth = Math.floor((width * listingColumnUnits[key]) / 1000)
widths[key] = columnWidth
usedWidth += columnWidth
}
widths.detail = Math.max(width - usedWidth, 44)
return widths
}
async function loadPublishOptions() {
try {
publishOptions.value = await fetchListingPublishOptions()
@@ -409,7 +461,6 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
const paddingX = 24
const paddingY = 20
const titleHeight = 44
const headerHeight = 34
const lineHeight = 18
const cellPaddingX = 8
const cellPaddingY = 10
@@ -430,6 +481,11 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
const maxLines = Math.max(...lineGroups.map(lines => lines.length), 1)
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2)
})
const headerLines = screenshotColumns.map(column =>
wrapCanvasText(measureCtx, column.label, column.width - cellPaddingX * 2)
)
const maxHeaderLines = Math.max(...headerLines.map(lines => lines.length), 1)
const headerHeight = Math.max(34, maxHeaderLines * lineHeight + cellPaddingY * 2)
const canvasHeight =
paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
const canvas = document.createElement('canvas')
@@ -459,8 +515,14 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
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)
for (const [columnIndex, column] of screenshotColumns.entries()) {
drawCellText(
ctx,
headerLines[columnIndex] ?? [column.label],
x + cellPaddingX,
y + cellPaddingY + 12,
lineHeight
)
x += column.width
}
drawLine(ctx, paddingX, y + headerHeight, paddingX + tableWidth, y + headerHeight)
@@ -579,6 +641,52 @@ function regionText(row: Listing) {
return regions.length ? regions.join('、') : '-'
}
function zoneCompensationText(row: Listing) {
const zone = row.is_premium ? '极品' : '普通'
const compensation =
row.compensation_plan === 'insurance'
? `无忧保险 ${(Number(row.insurance_fee_bps || 0) / 100).toFixed(1)}%`
: '普通赔付'
return `${zone}\n${compensation}`
}
function regionLoginCoinText(row: Listing) {
return [regionText(row), row.login_platform || '-', listingCoinM(row)].join('\n')
}
function insuranceSlotText(row: Listing) {
const value = assetText(row, 'season_insurance')
return value === '-' ? '-' : insuranceSlotLabel(value)
}
function coreAssetText(row: Listing) {
return [
levelText(row, 'stamina_level'),
levelText(row, 'load_level'),
insuranceSlotText(row),
].join('/')
}
function coreAssetTitle(row: Listing) {
const insurance = assetText(row, 'season_insurance')
const slots = insurance === '-' ? '-' : insuranceSlotLabel(insurance)
const insuranceText =
insurance === '-' || slots === insurance ? insurance : `${insurance}=${slots}格`
return `体力 ${levelText(row, 'stamina_level')} / 负重 ${levelText(row, 'load_level')} / 保险 ${insuranceText}`
}
function levelKdText(row: Listing) {
return [assetText(row, 'fire_level'), assetText(row, 'secret_kd')].join('/')
}
function equipmentText(row: Listing) {
return [
resourceText(row, 'awmAmmo'),
resourceText(row, 'helmet6'),
resourceText(row, 'armor6'),
].join('/')
}
function resourceText(row: Listing, key: string) {
const quantity = getResourceQuantity(row, key)
return quantity > 0 ? formatQuantity(quantity) : '-'
@@ -589,8 +697,9 @@ function skinGroupText(row: Listing, groupKey: string) {
return skins.length ? skins.join('、') : '-'
}
function characterAndWeaponSkinText(row: Listing) {
function skinAssetText(row: Listing) {
const groups = [
{ label: '刀', key: 'melee' },
{ label: '红皮', key: 'operatorRed' },
{ label: '金皮', key: 'operatorGold' },
{ label: '武器', key: 'weapon' },
@@ -601,7 +710,19 @@ function characterAndWeaponSkinText(row: Listing) {
return names.length ? `${group.label}:${names.join('、')}` : ''
})
.filter(Boolean)
return parts.length ? parts.join(' / ') : '-'
return parts.length ? parts.join('\n') : '-'
}
function faceHeroText(row: Listing) {
return [faceOwnerText(row), allHeroText(row)].join('/')
}
function priceRentDepositText(row: Listing) {
return [
renterPureCoinPriceText(row),
listingPrice(row),
formatCentWithSymbol(row.deposit_amount_cent),
].join('\n')
}
function ownerRemarkText(row: Listing) {
@@ -619,10 +740,6 @@ function allHeroText(row: Listing) {
return value === '是' || value === '否' ? value : '-'
}
function rentAndDepositText(row: Listing) {
return `${listingPrice(row)}/${formatCentWithSymbol(row.deposit_amount_cent)}`
}
// 租客纯币价格:不含额外消耗品的纯币部分(快照 buyer_coin_base_price 或 总价减消耗品)。
function renterPureCoinPriceText(row: Listing) {
const price = getListingRentPrice(row)
@@ -649,7 +766,7 @@ function formatQuantity(value: number) {
</script>
<template>
<section class="page admin-listings-page">
<section ref="pageRef" class="page admin-listings-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listings</p>
@@ -942,99 +1059,102 @@ function formatQuantity(value: number) {
stripe
empty-text="暂无商品"
>
<el-table-column label="商品编码" width="88">
<el-table-column label="商品编码" :width="listingColumnWidths.code">
<template #default="{ row }">
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
</template>
</el-table-column>
<el-table-column label="专区" width="72">
<el-table-column
label="专区/赔付"
:width="listingColumnWidths.zone"
label-class-name="listing-merged-header"
>
<template #default="{ row }">
<el-tag v-if="row.is_premium" type="warning" size="small">极品</el-tag>
<span v-else>普通</span>
</template>
</el-table-column>
<el-table-column label="赔付方案" width="102">
<template #default="{ row }">
<span v-if="row.compensation_plan === 'insurance'">
无忧保险 {{ (Number(row.insurance_fee_bps || 0) / 100).toFixed(1) }}%
</span>
<span v-else>普通赔付</span>
</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="60">
<template #default="{ row }">
<span :class="{ 'face-owner-no': faceOwnerText(row) === '否' }">
{{ faceOwnerText(row) }}
<span class="listing-multiline-cell" :class="{ 'listing-premium-text': row.is_premium }">
{{ zoneCompensationText(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="全英雄" width="74">
<template #default="{ row }">{{ allHeroText(row) }}</template>
<el-table-column
label="地区/上号/纯币"
:width="listingColumnWidths.region"
show-overflow-tooltip
label-class-name="listing-merged-header"
>
<template #default="{ row }">
<span class="listing-multiline-cell">{{ regionLoginCoinText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="号主备注" min-width="150">
<el-table-column
label="体力/负重/保险"
:width="listingColumnWidths.core"
label-class-name="listing-merged-header"
>
<template #default="{ row }">
<span :title="coreAssetTitle(row)">{{ coreAssetText(row) }}</span>
</template>
</el-table-column>
<el-table-column
label="等级/KD"
:width="listingColumnWidths.level"
label-class-name="listing-merged-header"
>
<template #default="{ row }">{{ levelKdText(row) }}</template>
</el-table-column>
<el-table-column
label="AWM/六头/六甲"
:width="listingColumnWidths.equipment"
label-class-name="listing-merged-header"
>
<template #default="{ row }">{{ equipmentText(row) }}</template>
</el-table-column>
<el-table-column
label="皮肤资产"
:width="listingColumnWidths.skin"
show-overflow-tooltip
label-class-name="listing-merged-header"
>
<template #default="{ row }">
<span class="listing-multiline-cell">{{ skinAssetText(row) }}</span>
</template>
</el-table-column>
<el-table-column
label="人脸/全英雄"
:width="listingColumnWidths.face"
label-class-name="listing-merged-header"
>
<template #default="{ row }">
<span
:class="{
'face-owner-no': faceOwnerText(row) === '否',
}"
>
{{ faceHeroText(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="号主备注" :width="listingColumnWidths.remark" show-overflow-tooltip>
<template #default="{ row }">{{ ownerRemarkText(row) }}</template>
</el-table-column>
<el-table-column label="租客纯币价格" width="105">
<el-table-column
label="纯币/租金/押金"
:width="listingColumnWidths.price"
label-class-name="listing-merged-header"
>
<template #default="{ row }">
<span :title="`租客纯币价格(不含消耗品)${renterPureCoinPriceText(row)}`">
{{ renterPureCoinPriceText(row) }}
</span>
<span class="listing-multiline-cell">{{ priceRentDepositText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="租期" width="82">
<el-table-column label="租期" :width="listingColumnWidths.period">
<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="90">
<template #default="{ row }">{{ rentAndDepositText(row) }}</template>
</el-table-column>
<el-table-column label="比例" width="62">
<el-table-column label="比例" :width="listingColumnWidths.ratio">
<template #default="{ row }">{{ formatRatio(row) }}</template>
</el-table-column>
<el-table-column label="详情" width="60">
<el-table-column label="详情" :width="listingColumnWidths.detail">
<template #default="{ row }">
<RouterLink :to="adminPath(`listings/${row.id}`)">
<el-button size="small">详情</el-button>
+15
View File
@@ -1161,6 +1161,12 @@ h1 {
overflow: visible;
}
.admin-listings-table.el-table th.listing-merged-header .cell {
white-space: normal;
line-height: 1.2;
overflow-wrap: anywhere;
}
.admin-listings-table .listing-code {
display: inline-block;
font-size: 11px;
@@ -1174,6 +1180,15 @@ h1 {
font-weight: 700;
}
.admin-listings-table .listing-premium-text {
color: #d97706;
font-weight: 700;
}
.admin-listings-table .listing-multiline-cell {
white-space: pre-line;
}
.admin-listings-table.el-table .el-table__cell {
padding: 8px 0;
}