diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue index 61dd471e..cec16b2d 100644 --- a/frontend/src/features/admin/views/AdminListingsView.vue +++ b/frontend/src/features/admin/views/AdminListingsView.vue @@ -55,8 +55,28 @@ const fallbackLevelOptions = ['1级', '2级', '3级', '4级', '5级', '6级', '7 const pageSize = ref(10) const currentPage = ref(1) +const pageRef = ref(null) +const listingTableWidth = ref(1296) const activeFilterPopover = ref('') 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 + 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) {