Files
hfb_sys/frontend/src/features/admin/views/AdminListingsView.vue
T

1015 lines
34 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 { ArrowDown, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import {
emptyListingPublishOptions,
fetchAdminListings,
fetchListingPublishOptions,
type AdminListingPage,
type AdminListingQuery,
type Listing,
} from '@/features/listings'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getResourceQuantity,
getSkinGroup,
} from '@/shared/utils/listingDisplay'
const filters = reactive<AdminListingQuery>({
keyword: '',
owner_id: '',
status: '',
review_status: '',
insurance: '',
stamina: '',
load: '',
skin_group: '',
skin_name: '',
min_coin: undefined,
max_coin: undefined,
min_deposit: undefined,
max_deposit: undefined,
})
type FilterPopover = 'asset' | 'skin'
type AssetFilterKey = 'insurance' | 'stamina' | 'load'
const fallbackInsuranceOptions = ['2*1', '2*2', '2*3', '3*3']
const fallbackLevelOptions = ['1级', '2级', '3级', '4级', '5级', '6级', '7级']
const pageSize = ref(10)
const currentPage = ref(1)
const activeFilterPopover = ref<FilterPopover | ''>('')
const publishOptions = ref(emptyListingPublishOptions)
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,
},
// 首次加载由 onMounted 控制:先恢复持久化的筛选/页码再查询
immediate: false,
})
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 insuranceOptions = computed(() =>
publishOptions.value.insurance_options.length
? publishOptions.value.insurance_options
: fallbackInsuranceOptions
)
const levelOptions = computed(() =>
publishOptions.value.level_options.length
? publishOptions.value.level_options
: fallbackLevelOptions
)
const skinFilterGroups = computed(() => publishOptions.value.skin_groups)
const assetFilterActive = computed(() =>
Boolean(filters.insurance || filters.stamina || filters.load)
)
const assetFilterLabel = computed(() => {
if (!assetFilterActive.value) return '全部资产'
return [
insuranceSlotLabel(filters.insurance || ''),
levelSlotLabel(filters.stamina || ''),
levelSlotLabel(filters.load || ''),
].join('/')
})
const skinFilterActive = computed(() => Boolean(filters.skin_group || filters.skin_name))
const skinFilterLabel = computed(() => {
if (filters.skin_name) return filters.skin_name
if (filters.skin_group) {
return skinFilterGroups.value.find(group => group.key === filters.skin_group)?.title || '皮肤'
}
return '全部皮肤'
})
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: 160, read: (row: Listing) => ownerRemarkText(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() {
closeFilterPopover()
currentPage.value = 1
await loadListings()
}
function resetFilters() {
filters.keyword = ''
filters.owner_id = ''
filters.status = ''
filters.review_status = ''
filters.min_coin = undefined
filters.max_coin = undefined
filters.min_deposit = undefined
filters.max_deposit = undefined
clearAssetFilters(false)
clearSkinFilters(false)
void queryListings()
}
// ========== 列表状态持久化 ==========
// 详情页返回/刷新后恢复筛选条件、页码与滚动位置,避免筛选丢失。
const adminListingsStateKey = 'hfb.admin.listings.state'
let stateSaveTimer: number | undefined
let scrollSaveTimer: number | undefined
function adminMainEl() {
return document.querySelector('.admin-main')
}
function adminMainScrollTop() {
return adminMainEl()?.scrollTop || 0
}
function saveListState() {
try {
window.sessionStorage.setItem(
adminListingsStateKey,
JSON.stringify({
filters: { ...filters },
page: currentPage.value,
pageSize: pageSize.value,
scrollTop: adminMainScrollTop(),
})
)
} catch {
// sessionStorage 不可用时仅损失状态恢复
}
}
function restoreListState() {
try {
const raw = window.sessionStorage.getItem(adminListingsStateKey)
if (!raw) return
const state = JSON.parse(raw) as {
filters?: AdminListingQuery
page?: number
pageSize?: number
}
if (state.filters && typeof state.filters === 'object') {
Object.assign(filters, state.filters)
}
if (typeof state.page === 'number' && state.page > 0) {
currentPage.value = state.page
}
if (typeof state.pageSize === 'number' && [10, 20, 50, 100].includes(state.pageSize)) {
pageSize.value = state.pageSize
}
} catch {
// 损坏的状态忽略,按默认条件加载
}
}
function restoreListScroll() {
try {
const raw = window.sessionStorage.getItem(adminListingsStateKey)
if (!raw) return
const state = JSON.parse(raw) as { scrollTop?: number }
const top = Number(state.scrollTop)
if (!Number.isFinite(top) || top <= 0) return
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const main = adminMainEl()
if (main) main.scrollTop = top
})
})
} catch {
// 忽略
}
}
function handleAdminMainScroll() {
if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer)
scrollSaveTimer = window.setTimeout(saveListState, 300)
}
// 筛选/页码变化时防抖保存;滚动位置随每次保存一起写入
watch(
[filters, currentPage, pageSize],
() => {
if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer)
stateSaveTimer = window.setTimeout(saveListState, 200)
},
{ deep: true }
)
onMounted(async () => {
restoreListState()
void loadPublishOptions()
await loadListings()
restoreListScroll()
adminMainEl()?.addEventListener('scroll', handleAdminMainScroll, { passive: true })
})
onBeforeUnmount(() => {
saveListState()
adminMainEl()?.removeEventListener('scroll', handleAdminMainScroll)
if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer)
if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer)
})
async function loadPublishOptions() {
try {
publishOptions.value = await fetchListingPublishOptions()
} catch {
publishOptions.value = emptyListingPublishOptions
ElMessage.warning('商品筛选选项加载失败,已使用默认资产选项')
}
}
function setFilterPopover(key: FilterPopover, visible: boolean) {
activeFilterPopover.value = visible
? key
: activeFilterPopover.value === key
? ''
: activeFilterPopover.value
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function setAssetFilter(key: AssetFilterKey, value: string) {
filters[key] = filters[key] === value ? '' : value
}
function clearAssetFilters(shouldQuery = false) {
filters.insurance = ''
filters.stamina = ''
filters.load = ''
if (shouldQuery) void queryListings()
}
function selectSkinFilter(group: string, name = '') {
filters.skin_group = group
filters.skin_name = name
closeFilterPopover()
}
function clearSkinFilters(shouldQuery = false) {
filters.skin_group = ''
filters.skin_name = ''
closeFilterPopover()
if (shouldQuery) void queryListings()
}
function insuranceSlotLabel(value: string) {
const parts = value.split('*').map(item => Number(item))
if (parts.length === 2 && parts.every(item => Number.isFinite(item) && item > 0)) {
const [rows = 0, columns = 0] = parts
return `${rows * columns}`
}
return value || '-'
}
function levelSlotLabel(value: string) {
return value ? value.replace(/级$/, '') : '-'
}
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 ownerRemarkText(row: Listing) {
const remark = row.description?.trim()
return remark || '-'
}
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" label-position="top">
<el-form-item class="filter-keyword" label="编号">
<el-input
v-model="filters.keyword"
clearable
placeholder="商品编号 / 订单编号"
@keyup.enter="queryListings"
@clear="queryListings"
/>
</el-form-item>
<el-form-item class="filter-owner" label="号主 ID">
<el-input v-model="filters.owner_id" clearable placeholder="筛选号主" />
</el-form-item>
<el-form-item class="filter-range" label="纯币(M)">
<div class="admin-range-filter">
<el-input-number
v-model="filters.min_coin"
:min="0"
:precision="1"
:step="10"
:controls="false"
placeholder="最低"
/>
<span class="admin-range-separator">-</span>
<el-input-number
v-model="filters.max_coin"
:min="0"
:precision="1"
:step="10"
:controls="false"
placeholder="最高"
/>
</div>
</el-form-item>
<el-form-item class="filter-range" label="押金(元)">
<div class="admin-range-filter">
<el-input-number
v-model="filters.min_deposit"
:min="0"
:precision="1"
:step="100"
:controls="false"
placeholder="最低"
/>
<span class="admin-range-separator">-</span>
<el-input-number
v-model="filters.max_deposit"
:min="0"
:precision="1"
:step="100"
:controls="false"
placeholder="最高"
/>
</div>
</el-form-item>
<el-form-item class="filter-select" label="商品状态">
<el-select v-model="filters.status" clearable placeholder="全部状态">
<el-option label="草稿" value="draft" />
<el-option label="已上架" value="published" />
<el-option label="已锁定" value="rented" />
<el-option label="已下架" value="offline" />
<el-option label="已完成" value="completed" />
<el-option label="异常" value="abnormal" />
</el-select>
</el-form-item>
<el-form-item class="filter-select" label="审核状态">
<el-select v-model="filters.review_status" clearable placeholder="全部审核">
<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 class="filter-select" label="资产">
<el-popover
:visible="activeFilterPopover === 'asset'"
trigger="click"
placement="bottom-start"
popper-class="admin-listing-filter-popover"
:width="360"
@update:visible="setFilterPopover('asset', $event)"
>
<template #reference>
<button
class="admin-filter-chip"
:class="{ active: assetFilterActive }"
type="button"
>
<span>{{ assetFilterLabel }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
</template>
<div class="admin-asset-filter-menu">
<div class="admin-filter-section">
<span class="admin-filter-title">保险</span>
<div class="admin-option-grid compact">
<button
type="button"
:class="{ active: !filters.insurance }"
@click="setAssetFilter('insurance', '')"
>
全部
</button>
<button
v-for="item in insuranceOptions"
:key="item"
type="button"
:class="{ active: filters.insurance === item }"
@click="setAssetFilter('insurance', item)"
>
{{ insuranceSlotLabel(item) }}
</button>
</div>
</div>
<div class="admin-filter-section">
<span class="admin-filter-title">体力</span>
<div class="admin-option-grid">
<button
type="button"
:class="{ active: !filters.stamina }"
@click="setAssetFilter('stamina', '')"
>
全部
</button>
<button
v-for="item in levelOptions"
:key="`stamina-${item}`"
type="button"
:class="{ active: filters.stamina === item }"
@click="setAssetFilter('stamina', item)"
>
{{ levelSlotLabel(item) }}
</button>
</div>
</div>
<div class="admin-filter-section">
<span class="admin-filter-title">负重</span>
<div class="admin-option-grid">
<button
type="button"
:class="{ active: !filters.load }"
@click="setAssetFilter('load', '')"
>
全部
</button>
<button
v-for="item in levelOptions"
:key="`load-${item}`"
type="button"
:class="{ active: filters.load === item }"
@click="setAssetFilter('load', item)"
>
{{ levelSlotLabel(item) }}
</button>
</div>
</div>
<button
v-if="assetFilterActive"
class="admin-filter-reset"
type="button"
@click="clearAssetFilters()"
>
清空资产筛选
</button>
</div>
</el-popover>
</el-form-item>
<el-form-item class="filter-skin" label="皮肤">
<el-popover
:visible="activeFilterPopover === 'skin'"
trigger="click"
placement="bottom-start"
popper-class="admin-listing-filter-popover"
:width="420"
@update:visible="setFilterPopover('skin', $event)"
>
<template #reference>
<button
class="admin-filter-chip wide"
:class="{ active: skinFilterActive }"
type="button"
>
<span>{{ skinFilterLabel }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
</template>
<div class="admin-skin-filter-menu">
<button
class="admin-filter-reset"
type="button"
:class="{ active: !filters.skin_group && !filters.skin_name }"
@click="clearSkinFilters()"
>
全部皮肤
</button>
<div v-if="!skinFilterGroups.length" class="admin-filter-empty">暂无皮肤选项</div>
<div
v-for="group in skinFilterGroups"
:key="group.key"
class="admin-filter-section"
>
<div class="admin-skin-filter-title">
<strong>{{ group.title }}</strong>
<button
type="button"
:class="{ active: filters.skin_group === group.key && !filters.skin_name }"
@click="selectSkinFilter(group.key)"
>
全部{{ group.title.replace('干员', '') }}
</button>
</div>
<div class="admin-skin-option-grid">
<button
v-for="skin in group.options"
:key="`${group.key}-${skin}`"
type="button"
:class="{
active: filters.skin_group === group.key && filters.skin_name === skin,
}"
@click="selectSkinFilter(group.key, skin)"
>
{{ skin }}
</button>
</div>
</div>
</div>
</el-popover>
</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="号主备注" min-width="150">
<template #default="{ row }">{{ ownerRemarkText(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="adminPath(`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>