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

1719 lines
58 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 { readError } from '@/shared/utils/error'
import {
ArrowDown,
Check,
CircleCheck,
CircleClose,
Clock,
Close,
Coin,
CopyDocument,
Document,
EditPen,
HelpFilled,
FullScreen,
Picture,
Pointer,
PriceTag,
Refresh,
Right,
Search,
Select,
TopRight,
User,
Warning,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adminPath } from '@/shared/utils/adminPath'
import { centToYuan, formatCent, formatMoneyWithSymbol } from '@/shared/utils/money'
import {
adjustListingReviewPrice,
approveListing,
fetchPendingReviewListings,
rejectListing,
type Listing,
} from '@/features/listings'
import ImageEditorDialog from '../components/ImageEditorDialog.vue'
import AdminListingReviewRejectDialog from '../components/AdminListingReviewRejectDialog.vue'
import AdminListingReviewPriceDialog from '../components/AdminListingReviewPriceDialog.vue'
import AdminListingReviewEvidenceDialog from '../components/AdminListingReviewEvidenceDialog.vue'
import AdminListingReviewShortcutsDialog from '../components/AdminListingReviewShortcutsDialog.vue'
import AdminListingReviewPreviewDialog, {
type PreviewScreenshotItem,
} from '../components/AdminListingReviewPreviewDialog.vue'
import {
assetRegions,
formatListingCode,
formatHafCoinM,
getCoinWan,
getListingConsumablePrice,
getListingResources,
getOnlineTimeText,
getResourceQuantity,
getSkinNames,
readAssetNumber,
readAssetString,
} from '@/shared/utils/listingDisplay'
import { formatDateTime } from '@/shared/utils/time'
type RiskLevel = 'danger' | 'warning' | 'info' | 'success'
interface RiskItem {
label: string
level: RiskLevel
type?: 'ban' | 'deposit' | 'screenshot' | 'general'
}
interface ReviewScreenshotItem {
url: string
label: string
categoryKey?: string
}
interface ChecklistItem {
key: string
label: string
status: 'provided' | 'default' | 'missing'
count: number
}
const _defaultScreenshotURL = '/api/listings/default-upload-screenshot?v=webp1'
function onThumbError(e: Event) {
const target = e.target as HTMLImageElement
if (target && target.src !== _defaultScreenshotURL) {
target.src = _defaultScreenshotURL
}
}
const sortOptions = [
{ label: '最新提交 (默认)', value: 'default' },
{ label: '风险优先 (封禁/默认图)', value: 'risk' },
{ label: '价格从高到低', value: 'price_desc' },
{ label: '价格从低到高', value: 'price_asc' },
{ label: '哈夫币从多到少', value: 'coin_desc' },
]
const standardSlots = [
{ key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
]
const loading = ref(false)
const submitting = ref(false)
const listings = ref<Listing[]>([])
const selectedID = ref<number | null>(null)
const activeListing = ref<Listing | null>(null)
const rejectDialogVisible = ref(false)
const evidenceListing = ref<Listing | null>(null)
const evidenceDialogVisible = ref(false)
const editorVisible = ref(false)
const editingScreenshot = ref<{ url: string; label: string; listing: Listing } | null>(null)
const priceAdjustListing = ref<Listing | null>(null)
const priceAdjustDialogVisible = ref(false)
const adjustingPrice = ref(false)
const filters = reactive({
keyword: '',
risk: 'all',
sortBy: 'default',
})
const previewURLs = reactive<Record<string, string>>({})
const createdObjectURLs = new Set<string>()
const lastRefreshedAt = ref<string>('')
const shortcutsDialogVisible = ref(false)
// Centered Preview Dialog & Press-and-Hold Peek
const previewVisible = ref(false)
const previewInitialIndex = ref(0)
const previewScreenshots = ref<ReviewScreenshotItem[]>([])
const isPeekMode = ref(false)
let cardHoldTimer: ReturnType<typeof setTimeout> | null = null
let didActivatePeek = false
const selectedListing = computed(
() => listings.value.find(item => item.id === selectedID.value) || listings.value[0] || null
)
const isDepositRisky = (row: Listing) => {
const consumable = getListingConsumablePrice(row)
return consumable > 0 && centToYuan(row.deposit_amount_cent) <= consumable
}
const filteredListings = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
const result = listings.value.filter(item => {
if (filters.risk === 'external' && !isExternalUpload(item)) return false
if (filters.risk === 'defaultImage' && !hasDefaultScreenshot(item)) return false
if (filters.risk === 'ban' && !hasBanRecord(item)) return false
if (filters.risk === 'deposit' && !isDepositRisky(item)) return false
if (!keyword) return true
return reviewSearchText(item).includes(keyword)
})
return result.sort((a, b) => {
if (filters.sortBy === 'risk') {
const scoreA =
(hasBanRecord(a) ? 10 : 0) + (hasDefaultScreenshot(a) ? 5 : 0) + (isDepositRisky(a) ? 3 : 0)
const scoreB =
(hasBanRecord(b) ? 10 : 0) + (hasDefaultScreenshot(b) ? 5 : 0) + (isDepositRisky(b) ? 3 : 0)
return scoreB - scoreA
}
if (filters.sortBy === 'price_desc') {
return b.price_cent - a.price_cent
}
if (filters.sortBy === 'price_asc') {
return a.price_cent - b.price_cent
}
if (filters.sortBy === 'coin_desc') {
return getCoinWan(b) - getCoinWan(a)
}
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
})
})
const currentSelectedIndex = computed(() =>
filteredListings.value.findIndex(item => item.id === selectedID.value)
)
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() =>
selectedListing.value ? getListingResources(selectedListing.value) : []
)
const selectedSkins = computed(() =>
selectedListing.value ? getSkinNames(selectedListing.value) : []
)
const selectedReviewScreenshots = computed(() =>
selectedListing.value ? getListingScreenshots(selectedListing.value) : []
)
// Checklist of required screenshots for the selected listing
const reviewChecklist = computed<ChecklistItem[]>(() => {
if (!selectedListing.value) return []
const item = selectedListing.value
const groups = item.asset_summary?.screenshot_groups
const hasGroups = typeof groups === 'object' && groups !== null
return standardSlots.map(slot => {
let urls: string[] = []
if (hasGroups) {
const raw = (groups as Record<string, unknown>)[slot.key]
if (Array.isArray(raw)) {
urls = raw.filter((u): u is string => typeof u === 'string' && Boolean(u))
}
} else {
const idx = standardSlots.findIndex(s => s.key === slot.key)
if (item.screenshot_urls && item.screenshot_urls[idx]) {
urls = [item.screenshot_urls[idx]]
}
}
if (!urls.length) {
return { key: slot.key, label: slot.label, status: 'missing', count: 0 }
}
if (urls.some(u => isDefaultScreenshot(u))) {
return { key: slot.key, label: slot.label, status: 'default', count: urls.length }
}
return { key: slot.key, label: slot.label, status: 'provided', count: urls.length }
})
})
watch(
() => selectedListing.value,
async listing => {
if (!listing) return
selectedID.value = listing.id
await nextTick()
loadScreenshotPreviews(listing)
},
{ immediate: true }
)
onMounted(() => {
void loadListings()
window.addEventListener('keydown', handleGlobalKeydown)
})
onBeforeUnmount(() => {
createdObjectURLs.forEach(url => URL.revokeObjectURL(url))
window.removeEventListener('keydown', handleGlobalKeydown)
})
async function loadListings() {
loading.value = true
try {
listings.value = await fetchPendingReviewListings()
if (!selectedID.value || !listings.value.some(item => item.id === selectedID.value)) {
selectedID.value = listings.value[0]?.id || null
}
const now = new Date()
lastRefreshedAt.value = now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} finally {
loading.value = false
}
}
function selectListing(row: Listing) {
selectedID.value = row.id
}
function selectNextListing() {
if (!filteredListings.value.length) return
const currentIndex = filteredListings.value.findIndex(item => item.id === selectedID.value)
if (currentIndex < filteredListings.value.length - 1) {
selectListing(filteredListings.value[currentIndex + 1]!)
} else {
ElMessage.info('已经是列表最后一条待审商品')
}
}
function selectPrevListing() {
if (!filteredListings.value.length) return
const currentIndex = filteredListings.value.findIndex(item => item.id === selectedID.value)
if (currentIndex > 0) {
selectListing(filteredListings.value[currentIndex - 1]!)
} else {
ElMessage.info('已经是列表第一条商品')
}
}
function handleGlobalKeydown(e: KeyboardEvent) {
const activeEl = document.activeElement
const isInput =
activeEl instanceof HTMLInputElement ||
activeEl instanceof HTMLTextAreaElement ||
activeEl?.getAttribute('contenteditable') === 'true'
if (isInput) return
const isAnyDialogOpen =
rejectDialogVisible.value ||
priceAdjustDialogVisible.value ||
evidenceDialogVisible.value ||
editorVisible.value ||
shortcutsDialogVisible.value ||
previewVisible.value
if (isAnyDialogOpen) {
if (e.key === 'Escape') {
rejectDialogVisible.value = false
priceAdjustDialogVisible.value = false
evidenceDialogVisible.value = false
editorVisible.value = false
shortcutsDialogVisible.value = false
previewVisible.value = false
}
return
}
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
e.preventDefault()
shortcutsDialogVisible.value = true
return
}
if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault()
selectNextListing()
return
}
if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault()
selectPrevListing()
return
}
if (selectedListing.value) {
if ((e.altKey && e.key.toLowerCase() === 'a') || (e.key === 'a' && !e.ctrlKey && !e.metaKey)) {
e.preventDefault()
void handleApprove(selectedListing.value)
return
}
if ((e.altKey && e.key.toLowerCase() === 'r') || (e.key === 'r' && !e.ctrlKey && !e.metaKey)) {
e.preventDefault()
openReject(selectedListing.value)
return
}
if ((e.altKey && e.key.toLowerCase() === 'p') || (e.key === 'p' && !e.ctrlKey && !e.metaKey)) {
e.preventDefault()
openPriceAdjust(selectedListing.value)
return
}
}
}
function replaceListing(next: Listing) {
const index = listings.value.findIndex(item => item.id === next.id)
if (index >= 0) {
const previous = listings.value[index]!
listings.value.splice(index, 1, {
...previous,
...next,
source_channel: next.source_channel || previous.source_channel,
})
} else {
listings.value.unshift(next)
}
selectedID.value = next.id
}
async function handleApprove(row: Listing) {
try {
await ElMessageBox.confirm(`确认通过「${row.title}」并立即上架?`, '审核通过确认', {
confirmButtonText: '通过并上架',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
submitting.value = true
try {
await approveListing(row.id)
ElMessage.success('审核通过,商品已成功上架')
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '审核失败'))
} finally {
submitting.value = false
}
}
function openReject(row: Listing) {
activeListing.value = row
rejectDialogVisible.value = true
}
async function onRejectConfirm(payload: { listingId: number; reason: string }) {
submitting.value = true
try {
await rejectListing(payload.listingId, payload.reason)
ElMessage.success('已拒绝发布并通知号主')
rejectDialogVisible.value = false
activeListing.value = null
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '拒绝失败'))
} finally {
submitting.value = false
}
}
function openPriceAdjust(row: Listing) {
priceAdjustListing.value = row
priceAdjustDialogVisible.value = true
}
async function onPriceAdjustSave(payload: {
listingId: number
payload: { buyer_ratio?: number; buyer_total_price_cent?: number; reason: string }
}) {
adjustingPrice.value = true
try {
const updated = await adjustListingReviewPrice(payload.listingId, payload.payload)
replaceListing(updated)
priceAdjustDialogVisible.value = false
priceAdjustListing.value = null
ElMessage.success('价格已更新并生效')
} catch (error) {
ElMessage.error(readError(error, '价格调整失败'))
} finally {
adjustingPrice.value = false
}
}
function openEvidence(row: Listing) {
evidenceListing.value = row
evidenceDialogVisible.value = true
loadScreenshotPreviews(row)
}
function money(value: number) {
return formatMoneyWithSymbol(value)
}
function quantity(value: number) {
const rounded = Math.round(Number(value || 0) * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
function assetText(row: Listing, key: string) {
return readAssetString(row, key) || '-'
}
function assetNumberText(row: Listing, key: string) {
const value = readAssetNumber(row, key)
return value > 0 ? quantity(value) : '-'
}
function priceBreakdown(row: Listing) {
const breakdown = row.asset_summary?.price_breakdown
return typeof breakdown === 'object' && breakdown !== null
? (breakdown as Record<string, unknown>)
: {}
}
function breakdownNumber(row: Listing, key: string) {
const value = priceBreakdown(row)[key]
if (typeof value === 'number') return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function ratioText(value: number) {
return value > 0 ? `1:${quantity(value)}` : '-'
}
function sellerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'seller_total_price')
if (value > 0) return value
const price = centToYuan(row.price_cent)
const fallback = price - getListingConsumablePrice(row)
return fallback > 0 ? fallback : price
}
function sellerCoinBasePrice(row: Listing) {
const value = breakdownNumber(row, 'seller_coin_base_price')
if (value > 0) return value
return Math.max(0, sellerTotalPrice(row) - getListingConsumablePrice(row))
}
function sellerRatio(row: Listing) {
const value =
breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
if (value > 0) return value
const base = sellerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0
}
function buyerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'buyer_total_price')
return value > 0 ? value : centToYuan(row.price_cent)
}
function buyerCoinBasePrice(row: Listing) {
const value = breakdownNumber(row, 'buyer_coin_base_price')
if (value > 0) return value
return Math.max(0, buyerTotalPrice(row) - getListingConsumablePrice(row))
}
function buyerRatio(row: Listing) {
const value = breakdownNumber(row, 'buyer_ratio')
if (value > 0) return value
const base = buyerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0
}
function platformMarkupAmount(row: Listing) {
const value = breakdownNumber(row, 'platform_markup_amount')
if (value > 0) return value
return Math.max(0, buyerTotalPrice(row) - sellerTotalPrice(row))
}
function platformMarkupPercentage(row: Listing) {
const markup = platformMarkupAmount(row)
const seller = sellerTotalPrice(row)
if (seller <= 0 || markup <= 0) return 0
return Math.round((markup / seller) * 1000) / 10
}
function priceRuleType(row: Listing) {
const value = priceBreakdown(row).platform_rule_type
return typeof value === 'string' && value ? value : 'none'
}
function priceRuleLabel(row: Listing) {
const labels: Record<string, string> = {
fixed_markup: '固定加价',
ratio_subtract: '比例加价',
external_upload: '外部上传价',
admin_adjusted: '后台已调整',
none: '无加价',
}
return labels[priceRuleType(row)] || priceRuleType(row)
}
function breakdownText(row: Listing, key: string) {
const value = priceBreakdown(row)[key]
return typeof value === 'string' && value.trim() ? value.trim() : ''
}
function adminAdjustReason(row: Listing) {
return breakdownText(row, 'admin_adjust_reason')
}
function priceRuleText(row: Listing) {
const markup = platformMarkupAmount(row)
if (priceRuleType(row) === 'admin_adjusted') {
const reason = adminAdjustReason(row)
return reason ? `原因:${reason}` : `${priceRuleLabel(row)} · 人工确认`
}
if (priceRuleType(row) === 'ratio_subtract') {
return `${priceRuleLabel(row)} · ${ratioText(sellerRatio(row))} → ${ratioText(buyerRatio(row))}`
}
if (markup > 0) return `${priceRuleLabel(row)} · +${money(markup)}`
return priceRuleLabel(row)
}
function dailyLossText(row: Listing) {
const value = readAssetNumber(row, 'daily_loss_m')
return value > 0 ? `${quantity(value)}M` : '未上传'
}
function uploadMeta(row: Listing) {
const meta = row.asset_summary?.import_meta
return typeof meta === 'object' && meta !== null ? (meta as Record<string, unknown>) : {}
}
function uploaderName(row: Listing) {
const value = uploadMeta(row).uploader_name
return typeof value === 'string' && value.trim() ? value : '-'
}
function contactPhone(row: Listing) {
const value = uploadMeta(row).contact_phone
return typeof value === 'string' && value.trim() ? value : '-'
}
function sourceChannel(row: Listing) {
if (typeof row.source_channel === 'string' && row.source_channel.trim()) {
return row.source_channel.trim()
}
return isExternalUpload(row) ? '未填写' : '站内发布'
}
function isDistributionListing(row: Listing) {
return Boolean(row.distribution_site_id || sourceChannel(row).startsWith('分销站'))
}
function ownerOnlineText(row: Listing) {
const text = row.asset_summary?.online_time_text
if (typeof text === 'string' && text.trim()) return text
return getOnlineTimeText(row) || '-'
}
function commonRegionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function banRecordText(row: Listing) {
return assetText(row, 'ban_record')
}
function hasBanRecord(row: Listing) {
const value = banRecordText(row)
return value !== '-' && !value.includes('无')
}
function isExternalUpload(row: Listing) {
return uploaderName(row) !== '-'
}
function hasDefaultScreenshot(row: Listing) {
return row.screenshot_urls?.some(url => isDefaultScreenshot(url)) || false
}
function isDefaultScreenshot(url: string) {
return url.includes('/api/listings/default-upload-screenshot')
}
function formatRelativeTime(dateStr?: string) {
if (!dateStr) return '-'
const timestamp = new Date(dateStr).getTime()
if (Number.isNaN(timestamp)) return dateStr
const diff = Date.now() - timestamp
if (diff < 60 * 1000) return '刚刚'
if (diff < 60 * 60 * 1000) return `${Math.floor(diff / (60 * 1000))} 分钟前`
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / (60 * 60 * 1000))} 小时前`
if (diff < 7 * 24 * 60 * 60 * 1000) return `${Math.floor(diff / (24 * 60 * 60 * 1000))} 天前`
return formatDateTime(dateStr).slice(0, 16)
}
async function copyText(text: string, label: string) {
if (!text || text === '-') {
ElMessage.info(`暂无可复制的${label}`)
return
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
ElMessage.success(`已复制${label}: ${text}`)
} catch {
ElMessage.info(`复制失败: ${text}`)
}
}
function riskItems(row: Listing): RiskItem[] {
const items: RiskItem[] = []
if (isExternalUpload(row)) {
items.push({
label: `外部上传:${uploaderName(row)} / 渠道:${sourceChannel(row)}`,
level: 'info',
type: 'general',
})
} else if (isDistributionListing(row)) {
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'success', type: 'general' })
} else {
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'info', type: 'general' })
}
const infoCount = items.length
if (!row.screenshot_urls?.length) {
items.push({ label: '缺少账号截图凭证', level: 'danger', type: 'screenshot' })
}
if (hasDefaultScreenshot(row)) {
items.push({ label: '使用系统默认占位图', level: 'warning', type: 'screenshot' })
}
if (hasBanRecord(row)) {
items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger', type: 'ban' })
}
if (isDepositRisky(row)) {
items.push({
label: `押金低于消耗品价值(¥${getListingConsumablePrice(row)})`,
level: 'danger',
type: 'deposit',
})
}
if (readAssetNumber(row, 'daily_loss_m') <= 0) {
items.push({ label: '缺少每日损耗数据', level: 'warning', type: 'general' })
}
if (readAssetNumber(row, 'fire_level') <= 40) {
items.push({ label: '烽火等级接近门槛', level: 'warning', type: 'general' })
}
if (!readAssetString(row, 'season_insurance')) {
items.push({ label: '未填写保险格数', level: 'warning', type: 'general' })
}
if (items.length === infoCount) {
items.push({ label: '未发现明显风控违规', level: 'info', type: 'general' })
}
return items
}
function riskTagType(level: RiskLevel) {
if (level === 'danger') return 'danger'
if (level === 'warning') return 'warning'
if (level === 'success') return 'success'
return 'info'
}
function reviewSearchText(row: Listing) {
return [
row.listing_no,
row.id,
row.account_id,
row.title,
row.owner_phone,
row.owner_nickname,
row.owner_id,
row.server_region,
row.login_platform,
row.rank_level,
uploaderName(row),
sourceChannel(row),
getSkinNames(row).join(' '),
]
.join(' ')
.toLowerCase()
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function loadScreenshotPreviews(row: Listing) {
for (const url of row.screenshot_urls || []) {
if (previewURLs[url]) continue
if (isDefaultScreenshot(url) || !extractObjectKey(url)) {
previewURLs[url] = url
continue
}
try {
const blob = await fetchAdminFileBlob(extractObjectKey(url))
const objectURL = URL.createObjectURL(blob)
createdObjectURLs.add(objectURL)
previewURLs[url] = objectURL
} catch {
previewURLs[url] = url
}
}
}
function getListingScreenshots(item: Listing | null): ReviewScreenshotItem[] {
if (!item) return []
const groups = item.asset_summary?.screenshot_groups
const slots = [
{ key: 'coin', label: '纯币截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
]
if (typeof groups === 'object' && groups !== null) {
const grouped = slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
categoryKey: slot.key,
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}))
})
if (grouped.length > 0) {
const groupedUrlSet = new Set(grouped.map(s => s.url))
const extraUrls = (item.screenshot_urls || []).filter(url => !groupedUrlSet.has(url))
const extras = extraUrls.map((url, index) => ({
categoryKey: 'extra',
label: `账号截图${grouped.length + index + 1}`,
url,
}))
return [...grouped, ...extras]
}
}
const defaultLabels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (item.screenshot_urls || []).map((url, index) => ({
categoryKey: standardSlots[index]?.key || 'extra',
label: defaultLabels[index] || `账号截图${index + 1}`,
url,
}))
}
function openPreviewDialog(images: ReviewScreenshotItem[], initialIndex = 0) {
if (!images.length) return
previewScreenshots.value = images
previewInitialIndex.value = Math.max(0, Math.min(initialIndex, images.length - 1))
isPeekMode.value = false
previewVisible.value = true
}
function startPeekPreview(images: ReviewScreenshotItem[], initialIndex = 0) {
if (!images.length) return
previewScreenshots.value = images
previewInitialIndex.value = Math.max(0, Math.min(initialIndex, images.length - 1))
isPeekMode.value = true
previewVisible.value = true
}
function handleCardMouseDown(images: ReviewScreenshotItem[], index: number, e: MouseEvent) {
if (e.button !== 0) return
didActivatePeek = false
if (cardHoldTimer) clearTimeout(cardHoldTimer)
cardHoldTimer = setTimeout(() => {
didActivatePeek = true
startPeekPreview(images, index)
}, 180)
}
function handleCardMouseUp() {
if (cardHoldTimer) {
clearTimeout(cardHoldTimer)
cardHoldTimer = null
}
}
function handleCardClick(images: ReviewScreenshotItem[], index: number) {
if (didActivatePeek) {
didActivatePeek = false
return
}
openPreviewDialog(images, index)
}
function handlePreviewEdit(payload: { shot: PreviewScreenshotItem; index: number }) {
openEditor(payload.shot, selectedListing.value)
}
function openRawScreenshot(url: string) {
if (isDefaultScreenshot(url)) {
window.open(url, '_blank')
return
}
const preview = previewURLs[url]
if (preview) {
window.open(preview, '_blank')
return
}
try {
const key = extractObjectKey(url)
if (!key) {
window.open(url, '_blank')
return
}
fetchAdminFileBlob(key)
.then(blob => {
window.open(URL.createObjectURL(blob), '_blank')
})
.catch(() => {
window.open(url, '_blank')
})
} catch {
window.open(url, '_blank')
}
}
function openEditor(shot: { url: string; label: string }, listing: Listing | null) {
if (!listing) return
editingScreenshot.value = {
url: shot.url,
label: shot.label,
listing,
}
editorVisible.value = true
}
async function handleScreenshotSaved(payload: { oldUrl: string; newUrl: string }) {
if (!editingScreenshot.value) return
const listingId = editingScreenshot.value.listing.id
const targetListing = listings.value.find(item => item.id === listingId)
if (!targetListing) return
const nextUrls = (targetListing.screenshot_urls || []).map(u =>
u === payload.oldUrl ? payload.newUrl : u
)
if (!nextUrls.includes(payload.newUrl)) {
nextUrls.push(payload.newUrl)
}
targetListing.screenshot_urls = nextUrls
const rawGroups = targetListing.asset_summary?.screenshot_groups
if (typeof rawGroups === 'object' && rawGroups !== null) {
for (const [k, v] of Object.entries(rawGroups as Record<string, unknown>)) {
if (Array.isArray(v)) {
;(rawGroups as Record<string, unknown>)[k] = v.map(u =>
u === payload.oldUrl ? payload.newUrl : u
)
}
}
}
if (evidenceListing.value?.id === listingId) {
evidenceListing.value.screenshot_urls = nextUrls
}
await loadScreenshotPreviews(targetListing)
editorVisible.value = false
editingScreenshot.value = null
}
</script>
<template>
<section class="page review-page">
<!-- Header Row -->
<div class="page-header-row review-header">
<div class="page-header">
<p class="eyebrow">Audit Workbench · 审核工作台</p>
<h1>商品审核</h1>
<p>核对账号资产、上号凭证、渠道溯源与定价风控,确保商品上架合规与买家保障。</p>
</div>
<div class="toolbar-actions">
<span v-if="lastRefreshedAt" class="refresh-stamp">
<el-icon><Clock /></el-icon>
最后刷新 {{ lastRefreshedAt }}
</span>
<el-button :icon="HelpFilled" plain @click="shortcutsDialogVisible = true">
快捷键 (?)
</el-button>
<el-button type="primary" :icon="Refresh" :loading="loading" @click="loadListings">
刷新待审池
</el-button>
</div>
</div>
<!-- Summary Metrics Dashboard -->
<div class="review-summary">
<button
class="summary-card summary-card--all"
:class="{ active: filters.risk === 'all' }"
type="button"
@click="filters.risk = 'all'"
>
<div class="summary-card-inner">
<div class="summary-card-icon">
<el-icon><Document /></el-icon>
</div>
<div class="summary-card-text">
<span>待审核总量</span>
<strong>{{ listings.length }}</strong>
<small>全站待审池</small>
</div>
</div>
</button>
<button
class="summary-card summary-card--external"
:class="{ active: filters.risk === 'external' }"
type="button"
@click="filters.risk = 'external'"
>
<div class="summary-card-inner">
<div class="summary-card-icon">
<el-icon><User /></el-icon>
</div>
<div class="summary-card-text">
<span>外部渠道提交</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
<small>商户 / 渠道合作</small>
</div>
</div>
</button>
<button
class="summary-card summary-card--warning"
:class="{ active: filters.risk === 'defaultImage' }"
type="button"
@click="filters.risk = 'defaultImage'"
>
<div class="summary-card-inner">
<div class="summary-card-icon">
<el-icon><Picture /></el-icon>
</div>
<div class="summary-card-text">
<span>默认截图瑕疵</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
<small>需督促补充真实截图</small>
</div>
</div>
</button>
<button
class="summary-card summary-card--danger"
:class="{ active: filters.risk === 'ban' }"
type="button"
@click="filters.risk = 'ban'"
>
<div class="summary-card-inner">
<div class="summary-card-icon">
<el-icon><WarningFilled /></el-icon>
</div>
<div class="summary-card-text">
<span>封禁违规高危</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
<small>存在安全拦截记录</small>
</div>
</div>
</button>
</div>
<!-- Review Workbench -->
<div class="review-workbench">
<!-- Left: Queue Sidebar -->
<aside class="review-queue">
<div class="queue-toolbar">
<el-input
v-model="filters.keyword"
:prefix-icon="Search"
clearable
placeholder="搜索编号、标题、客服、渠道、段位、皮肤..."
/>
<div class="queue-filter-row">
<el-select v-model="filters.risk" class="queue-select" placeholder="风险分类">
<el-option label="全部待审" value="all" />
<el-option label="外部上传" value="external" />
<el-option label="默认截图" value="defaultImage" />
<el-option label="封禁记录" value="ban" />
<el-option label="押金风险" value="deposit" />
</el-select>
<el-select v-model="filters.sortBy" class="queue-select" placeholder="排序规则">
<el-option
v-for="opt in sortOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="queue-count-strip">
<span
>共 {{ filteredListings.length }} 项 (当前第
{{ currentSelectedIndex >= 0 ? currentSelectedIndex + 1 : 1 }} 项)</span
>
<span class="shortcut-tip">按 [↑ / ↓] 快速切换</span>
</div>
</div>
<div v-loading="loading" class="queue-list">
<button
v-for="item in filteredListings"
:key="item.id"
class="queue-item"
:class="{
active: selectedListing?.id === item.id,
'has-ban': hasBanRecord(item),
'has-default-shot': hasDefaultScreenshot(item),
'has-deposit-risk': isDepositRisky(item),
}"
type="button"
@click="selectListing(item)"
>
<div class="item-accent-bar" />
<div class="queue-card-content">
<div class="queue-top-line">
<span class="queue-code">#{{ formatListingCode(item) }}</span>
<span class="queue-coin">
<el-icon><Coin /></el-icon>
{{ formatHafCoinM(getCoinWan(item)) }}
</span>
</div>
<div class="queue-main-row">
<div class="queue-thumb">
<img
:src="
previewURLs[item.screenshot_urls?.[0] || ''] ||
item.screenshot_urls?.[0] ||
_defaultScreenshotURL
"
alt="缩略图"
loading="lazy"
@error="onThumbError"
/>
<span v-if="hasDefaultScreenshot(item)" class="thumb-warning-badge">默认图</span>
</div>
<div class="queue-main-info">
<strong class="queue-title" :title="item.title">{{ item.title }}</strong>
<div class="queue-specs">
<span class="spec-pill">{{ item.rank_level || '段位未知' }}</span>
<span class="spec-pill">{{
assetText(item, 'season_insurance') || '保险未填'
}}</span>
<span class="spec-pill">KD {{ assetNumberText(item, 'secret_kd') }}</span>
</div>
</div>
</div>
<div class="queue-footer">
<div class="queue-price-block">
<strong class="queue-price">¥{{ formatCent(item.price_cent) }}</strong>
<span class="queue-deposit">押 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
</div>
<div class="queue-tags">
<el-tag
size="small"
:type="isDistributionListing(item) ? 'success' : 'primary'"
effect="plain"
>{{ sourceChannel(item) }}</el-tag
>
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
uploaderName(item)
}}</el-tag>
<el-tag v-if="hasBanRecord(item)" size="small" type="danger" effect="dark">
封禁
</el-tag>
</div>
</div>
</div>
</button>
<el-empty
v-if="!loading && !filteredListings.length"
description="暂无符合条件的待审核商品"
:image-size="80"
/>
</div>
</aside>
<!-- Right: Review Details & Decision Workbench -->
<main v-if="selectedListing" class="review-detail">
<!-- 1. Hero Title & Action Panel -->
<section class="review-hero-panel">
<div class="hero-top-row">
<div class="hero-meta-strip">
<span
class="hero-code-badge"
@click="copyText(formatListingCode(selectedListing), '商品编号')"
>
商品编号 #{{ formatListingCode(selectedListing) }}
<el-icon class="copy-icon" title="点击复制编号"><CopyDocument /></el-icon>
</span>
<span class="hero-submit-time">
<el-icon><Clock /></el-icon>
{{ formatDateTime(selectedListing.created_at || selectedListing.updated_at) }}
({{ formatRelativeTime(selectedListing.created_at || selectedListing.updated_at) }})
</span>
<el-tag size="small" type="warning" effect="dark">待审核</el-tag>
</div>
<div class="hero-actions">
<RouterLink
:to="adminPath(`listings/${selectedListing.id}`)"
target="_blank"
class="action-link"
>
<el-button :icon="TopRight" plain>详情页</el-button>
</RouterLink>
<el-button
:icon="PriceTag"
type="primary"
plain
@click="openPriceAdjust(selectedListing)"
>
调整价格
</el-button>
<el-button
:icon="Close"
type="danger"
:loading="submitting"
@click="openReject(selectedListing)"
>
拒绝 (R)
</el-button>
<el-button
:icon="Check"
type="primary"
class="btn-approve"
:loading="submitting"
@click="handleApprove(selectedListing)"
>
通过并上架 (A)
</el-button>
</div>
</div>
<h2 class="hero-title">{{ selectedListing.title }}</h2>
<div class="risk-tag-strip">
<el-tag
v-for="risk in activeRisks"
:key="risk.label"
:type="riskTagType(risk.level)"
effect="light"
round
>
{{ risk.label }}
</el-tag>
</div>
</section>
<!-- 2. Interactive Pricing Pipeline Flow -->
<section class="review-panel price-review-panel">
<div class="panel-heading">
<div class="heading-left">
<el-icon class="heading-icon"><PriceTag /></el-icon>
<div>
<h3>价格核算与加价决策流</h3>
<p>核对卖家提交价格、平台加价规则与最终买家展示价格。</p>
</div>
</div>
<el-button type="primary" plain size="small" @click="openPriceAdjust(selectedListing)">
<el-icon><EditPen /></el-icon>
调整审核价格
</el-button>
</div>
<div class="pricing-pipeline">
<!-- Step 1: Seller Price -->
<div class="pipeline-card pipeline-card--seller">
<div class="card-badge">卖家发布价</div>
<strong class="card-value">{{ money(sellerTotalPrice(selectedListing)) }}</strong>
<div class="card-meta">
<span
>发布比例 <strong>{{ ratioText(sellerRatio(selectedListing)) }}</strong></span
>
</div>
<div class="card-breakdown">
<span>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }}</span>
<span>物品 {{ money(getListingConsumablePrice(selectedListing)) }}</span>
</div>
</div>
<div class="pipeline-arrow">
<el-icon><Right /></el-icon>
<span class="arrow-label">平台加价</span>
</div>
<!-- Step 2: Markup Rule -->
<div class="pipeline-card pipeline-card--rule">
<div class="card-badge">{{ priceRuleLabel(selectedListing) }}</div>
<strong class="card-value markup-value"
>+{{ money(platformMarkupAmount(selectedListing)) }}</strong
>
<div class="card-meta">
<span>{{ priceRuleText(selectedListing) }}</span>
</div>
<div class="card-breakdown">
<span>毛利率 ~{{ platformMarkupPercentage(selectedListing) }}%</span>
<span
v-if="adminAdjustReason(selectedListing)"
class="admin-reason"
:title="adminAdjustReason(selectedListing)"
>
已人工调整
</span>
</div>
</div>
<div class="pipeline-arrow">
<el-icon><Right /></el-icon>
<span class="arrow-label">最终展示</span>
</div>
<!-- Step 3: Buyer Display Price -->
<div class="pipeline-card pipeline-card--buyer">
<div class="card-badge card-badge--highlight">买家端展示售价</div>
<strong class="card-value card-value--highlight">{{
money(buyerTotalPrice(selectedListing))
}}</strong>
<div class="card-meta">
<span
>展示比例 <strong>{{ ratioText(buyerRatio(selectedListing)) }}</strong></span
>
</div>
<div class="card-breakdown">
<span>纯币 {{ money(buyerCoinBasePrice(selectedListing)) }}</span>
<span>包含消耗品</span>
</div>
</div>
<!-- Step 4: Deposit & Protection -->
<div class="pipeline-card pipeline-card--deposit">
<div class="card-badge">押金与风控</div>
<strong class="card-value"
>¥{{ formatCent(selectedListing.deposit_amount_cent) }}</strong
>
<div class="card-meta">
<span>每日损耗 {{ dailyLossText(selectedListing) }}</span>
</div>
<div class="card-breakdown">
<span :class="{ 'warning-text': isDepositRisky(selectedListing) }">
{{ isDepositRisky(selectedListing) ? '⚠️ 押金低于消耗品' : '✅ 押金覆盖充足' }}
</span>
<span>哈夫币 {{ formatHafCoinM(getCoinWan(selectedListing)) }}</span>
</div>
</div>
</div>
</section>
<!-- 3. Dual Columns: Core Game Specs & Origin Info -->
<section class="review-grid">
<!-- Card A: Game Attributes -->
<div class="review-panel spec-panel">
<div class="panel-heading">
<div class="heading-left">
<el-icon class="heading-icon"><Select /></el-icon>
<h3>账号核心战备属性</h3>
</div>
</div>
<div class="spec-grid">
<div class="spec-item">
<span class="spec-label">区服渠道</span>
<strong class="spec-val">{{ selectedListing.server_region || '-' }}</strong>
</div>
<div class="spec-item">
<span class="spec-label">登录方式</span>
<strong class="spec-val">{{ selectedListing.login_platform || '-' }}</strong>
</div>
<div class="spec-item">
<span class="spec-label">段位评级</span>
<span class="rank-badge">{{ selectedListing.rank_level || '-' }}</span>
</div>
<div class="spec-item">
<span class="spec-label">烽火等级</span>
<strong class="spec-val"
>{{ assetNumberText(selectedListing, 'fire_level') }} 级</strong
>
</div>
<div class="spec-item">
<span class="spec-label">保险箱格数</span>
<strong class="spec-val">{{
assetText(selectedListing, 'season_insurance')
}}</strong>
</div>
<div class="spec-item">
<span class="spec-label">绝密 KD</span>
<strong class="spec-val">{{
assetNumberText(selectedListing, 'secret_kd')
}}</strong>
</div>
<div class="spec-item">
<span class="spec-label">体力 / 负重</span>
<strong class="spec-val">
{{ assetText(selectedListing, 'stamina_level') }} /
{{ assetText(selectedListing, 'load_level') }}
</strong>
</div>
<div
class="spec-item spec-item--full"
:class="{ 'ban-warning': hasBanRecord(selectedListing) }"
>
<span class="spec-label">封禁核验</span>
<div class="spec-val ban-val">
<el-icon v-if="hasBanRecord(selectedListing)" class="ban-icon"
><WarningFilled
/></el-icon>
<el-icon v-else class="safe-icon"><CircleCheck /></el-icon>
<span>{{ banRecordText(selectedListing) }}</span>
</div>
</div>
</div>
</div>
<!-- Card B: Upload & Ownership Information -->
<div class="review-panel spec-panel">
<div class="panel-heading">
<div class="heading-left">
<el-icon class="heading-icon"><User /></el-icon>
<h3>来源溯源与号主信息</h3>
</div>
<div class="panel-tags">
<el-tag size="small" type="primary" effect="plain">{{
sourceChannel(selectedListing)
}}</el-tag>
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
>外部上传</el-tag
>
</div>
</div>
<div class="spec-grid">
<div class="spec-item">
<span class="spec-label">来源渠道</span>
<strong class="spec-val">{{ sourceChannel(selectedListing) }}</strong>
</div>
<div class="spec-item">
<span class="spec-label">上传经办人</span>
<strong class="spec-val">{{ uploaderName(selectedListing) }}</strong>
</div>
<div class="spec-item">
<span class="spec-label">号主身份</span>
<strong class="spec-val">
{{
selectedListing.owner_nickname ||
selectedListing.owner_phone ||
`UID: ${selectedListing.owner_id}`
}}
</strong>
</div>
<div class="spec-item">
<span class="spec-label">联系电话</span>
<div class="spec-val copyable-val">
<span>{{ contactPhone(selectedListing) }}</span>
<el-button
v-if="contactPhone(selectedListing) !== '-'"
size="small"
text
:icon="CopyDocument"
title="复制手机号"
@click="copyText(contactPhone(selectedListing), '联系电话')"
/>
</div>
</div>
<div class="spec-item">
<span class="spec-label">常用地区</span>
<strong class="spec-val">{{ commonRegionText(selectedListing) }}</strong>
</div>
<div class="spec-item">
<span class="spec-label">在线时段</span>
<strong class="spec-val">{{ ownerOnlineText(selectedListing) }}</strong>
</div>
<div class="spec-item spec-item--full">
<span class="spec-label">提交时间</span>
<strong class="spec-val">{{ formatDateTime(selectedListing.updated_at) }}</strong>
</div>
</div>
</div>
</section>
<!-- 4. Inventory & Consumables -->
<section class="review-panel inventory-panel">
<div class="panel-heading">
<div class="heading-left">
<el-icon class="heading-icon"><Coin /></el-icon>
<h3>物资装备与外观资产</h3>
</div>
<div class="consumable-total-badge">
<span>额外物资估值</span>
<strong>{{ money(getListingConsumablePrice(selectedListing)) }}</strong>
</div>
</div>
<!-- Key Heavy Gear Badges -->
<div class="heavy-gear-grid">
<div class="gear-card gear-card--ammo">
<div class="gear-icon">🎯</div>
<div class="gear-info">
<span>AWM 专用子弹</span>
<strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }} <em>发</em></strong>
</div>
</div>
<div class="gear-card gear-card--helmet">
<div class="gear-icon">🪖</div>
<div class="gear-info">
<span>6级高级头盔</span>
<strong>{{ getResourceQuantity(selectedListing, 'helmet6') }} <em>顶</em></strong>
</div>
</div>
<div class="gear-card gear-card--armor">
<div class="gear-icon">🛡️</div>
<div class="gear-info">
<span>6级高级防弹衣</span>
<strong>{{ getResourceQuantity(selectedListing, 'armor6') }} <em>件</em></strong>
</div>
</div>
</div>
<!-- Resources Detail Table -->
<div v-if="selectedResources.length" class="resource-pill-grid">
<div v-for="res in selectedResources" :key="res.key" class="resource-chip">
<span class="res-name">{{ res.label }}</span>
<strong class="res-qty">×{{ res.quantity }}</strong>
<small class="res-mode">{{ res.mode }} · {{ res.price }}</small>
</div>
</div>
<!-- Skins Collection -->
<div class="skins-section">
<div class="skins-header">
<span>皮肤外观 (共 {{ selectedSkins.length }} 件)</span>
</div>
<div v-if="selectedSkins.length" class="skin-chips-wrapper">
<span v-for="skin in selectedSkins" :key="skin" class="skin-chip">
{{ skin }}
</span>
</div>
<p v-else class="empty-skins-tip">暂无登记皮肤数据</p>
</div>
</section>
<!-- 5. Screenshots Review & Verification Suite -->
<section class="review-panel screenshots-panel">
<div class="panel-heading">
<div class="heading-left">
<el-icon class="heading-icon"><Picture /></el-icon>
<div>
<h3>账号截图凭证审核</h3>
<p>核对各分类凭证真实性,点击放大画廊预览,支持直接裁剪打码替换。</p>
</div>
</div>
<div class="panel-actions">
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)">
查看全部凭证 ({{ selectedReviewScreenshots.length }})
</el-button>
</div>
</div>
<!-- 5-Point Verification Checklist Bar -->
<div class="verification-checklist">
<div
v-for="item in reviewChecklist"
:key="item.key"
class="check-item"
:class="`check-item--${item.status}`"
>
<el-icon v-if="item.status === 'provided'" class="check-icon"
><CircleCheck
/></el-icon>
<el-icon v-else-if="item.status === 'default'" class="check-icon"
><Warning
/></el-icon>
<el-icon v-else class="check-icon"><CircleClose /></el-icon>
<span class="check-label">{{ item.label }}</span>
<span class="check-status-tag">
{{
item.status === 'provided'
? `已提供 (${item.count})`
: item.status === 'default'
? '默认图'
: '未提供'
}}
</span>
</div>
</div>
<!-- Screenshots Gallery Grid -->
<div v-if="selectedReviewScreenshots.length" class="screenshot-gallery-grid">
<div
v-for="(shot, index) in selectedReviewScreenshots"
:key="shot.url"
class="screenshot-card"
@mousedown="handleCardMouseDown(selectedReviewScreenshots, index, $event)"
@mouseup="handleCardMouseUp"
@mouseleave="handleCardMouseUp"
@click="handleCardClick(selectedReviewScreenshots, index)"
>
<div class="image-wrapper">
<img :src="previewURLs[shot.url] || shot.url" :alt="shot.label" loading="lazy" />
<span v-if="isDefaultScreenshot(shot.url)" class="default-badge">
<el-icon><Warning /></el-icon> 默认占位图
</span>
<span class="category-tag">{{ shot.label }}</span>
<!-- Hover Overlay Actions -->
<div class="image-overlay" @click.stop>
<el-button
size="small"
type="primary"
:icon="Pointer"
circle
title="按住速览(松手即关闭)"
@mousedown.stop.prevent="startPeekPreview(selectedReviewScreenshots, index)"
@touchstart.stop.prevent="startPeekPreview(selectedReviewScreenshots, index)"
/>
<el-button
size="small"
type="primary"
:icon="FullScreen"
circle
title="弹窗大图 (Esc关闭)"
@click.stop="openPreviewDialog(selectedReviewScreenshots, index)"
/>
<el-button
size="small"
type="warning"
:icon="EditPen"
circle
title="在线裁剪/马赛克打码"
@click.stop="openEditor(shot, selectedListing)"
/>
<el-button
size="small"
:icon="TopRight"
circle
title="新标签页打开原图"
@click.stop="openRawScreenshot(shot.url)"
/>
</div>
</div>
</div>
</div>
<div v-else class="empty-warning">
<el-icon><WarningFilled /></el-icon>
<span>该商品未上传任何截图凭证,请谨慎审核或直接拒绝!</span>
</div>
</section>
</main>
<!-- Empty State -->
<main v-else class="review-detail empty-detail">
<el-empty description="待审核队列为空,暂无需要审核的商品" :image-size="120" />
</main>
</div>
<!-- Floating Sticky Action Dock (Bottom of page for fast decisions) -->
<div v-if="selectedListing" class="floating-dock">
<div class="dock-info">
<span class="dock-code">#{{ formatListingCode(selectedListing) }}</span>
<span class="dock-title" :title="selectedListing.title">{{ selectedListing.title }}</span>
<span class="dock-price">售价 {{ money(buyerTotalPrice(selectedListing)) }}</span>
<span v-if="hasBanRecord(selectedListing)" class="dock-risk-tag">🚨 存在封禁</span>
</div>
<div class="dock-actions">
<el-button :icon="PriceTag" size="default" plain @click="openPriceAdjust(selectedListing)">
调整价格 (P)
</el-button>
<el-button
:icon="Close"
size="default"
type="danger"
:loading="submitting"
@click="openReject(selectedListing)"
>
拒绝 (R)
</el-button>
<el-button
:icon="Check"
size="default"
type="primary"
class="btn-approve-dock"
:loading="submitting"
@click="handleApprove(selectedListing)"
>
通过并上架 (A)
</el-button>
<el-button
size="default"
:icon="ArrowDown"
title="切换下一个 (J)"
@click="selectNextListing"
>
下一单 (J)
</el-button>
</div>
</div>
<!-- Centered Screenshot Preview Modal (Supports Peek On Hold & Persistent Dialog) -->
<AdminListingReviewPreviewDialog
v-model:visible="previewVisible"
v-model:is-peek-mode="isPeekMode"
:images="previewScreenshots"
:initial-index="previewInitialIndex"
:preview-urls="previewURLs"
@edit="handlePreviewEdit"
@open-raw="openRawScreenshot"
/>
<!-- Reject Reason Dialog Component -->
<AdminListingReviewRejectDialog
v-model:visible="rejectDialogVisible"
:listing="activeListing"
:submitting="submitting"
@reject="onRejectConfirm"
/>
<!-- Price Adjust Dialog Component -->
<AdminListingReviewPriceDialog
v-model:visible="priceAdjustDialogVisible"
:listing="priceAdjustListing"
:loading="adjustingPrice"
@save="onPriceAdjustSave"
/>
<!-- Evidence All Screenshots Modal Component -->
<AdminListingReviewEvidenceDialog
v-model:visible="evidenceDialogVisible"
:listing="evidenceListing"
:preview-urls="previewURLs"
@view-image="openPreviewDialog($event.images, $event.index)"
@peek-image="startPeekPreview($event.images, $event.index)"
@edit-screenshot="openEditor($event.shot, $event.listing)"
/>
<!-- Keyboard Shortcuts Cheatsheet Modal Component -->
<AdminListingReviewShortcutsDialog v-model:visible="shortcutsDialogVisible" />
<!-- Online Image Editor Dialog -->
<ImageEditorDialog
v-model:visible="editorVisible"
:listing="editingScreenshot?.listing || null"
:image-url="editingScreenshot?.url || ''"
:image-label="editingScreenshot?.label || ''"
@saved="handleScreenshotSaved"
/>
</section>
</template>
<style scoped src="./AdminListingReviewView.css"></style>