1015 lines
34 KiB
Vue
1015 lines
34 KiB
Vue
<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>
|