1719 lines
58 KiB
Vue
1719 lines
58 KiB
Vue
<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>
|