Files
hfb_sys/frontend/src/features/admin/views/AdminListingReviewView.vue
T
2026-07-25 14:34:03 +08:00

1648 lines
43 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 { Check, Close, Picture, Refresh, Search, 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, yuanToCent } from '@/shared/utils/money'
import {
adjustListingReviewPrice,
approveListing,
fetchPendingReviewListings,
rejectListing,
type Listing,
} from '@/features/listings'
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'
interface RiskItem {
label: string
level: RiskLevel
}
const defaultScreenshotURL = '/api/listings/default-upload-screenshot?v=webp1'
const rejectReasonOptions = [
'默认截图,需补充真实截图',
'账号资产信息不完整',
'价格或押金异常',
'封禁记录需补充说明',
'联系方式异常',
]
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 evidenceListing = ref<Listing | null>(null)
const rejectReason = ref('')
const priceAdjustListing = ref<Listing | null>(null)
const adjustingPrice = ref(false)
const priceAdjustMode = ref<'ratio' | 'price'>('ratio')
const priceAdjustForm = reactive({
buyer_ratio: 0,
buyer_total_price: 0,
reason: '',
})
const filters = reactive({
keyword: '',
risk: 'all',
})
const previewURLs = reactive<Record<string, string>>({})
const createdObjectURLs = new Set<string>()
const selectedListing = computed(
() => listings.value.find(item => item.id === selectedID.value) || listings.value[0] || null
)
const filteredListings = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return 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 (!keyword) return true
return reviewSearchText(item).includes(keyword)
})
})
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 priceAdjustPreview = computed(() =>
priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null
)
watch(
() => selectedListing.value,
async listing => {
if (!listing) return
selectedID.value = listing.id
await nextTick()
loadScreenshotPreviews(listing)
},
{ immediate: true }
)
watch(priceAdjustMode, (mode, previousMode) => {
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
if (mode === 'price') {
priceAdjustForm.buyer_total_price =
preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
} else {
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
}
})
onMounted(loadListings)
onBeforeUnmount(() => {
createdObjectURLs.forEach(url => URL.revokeObjectURL(url))
})
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
}
} finally {
loading.value = false
}
}
function selectListing(row: Listing) {
selectedID.value = row.id
}
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: 'warning',
})
} 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
rejectReason.value = row.review_reason || ''
}
function openPriceAdjust(row: Listing) {
priceAdjustListing.value = row
priceAdjustMode.value = 'ratio'
priceAdjustForm.buyer_ratio = buyerRatio(row)
priceAdjustForm.buyer_total_price = buyerTotalPrice(row)
priceAdjustForm.reason = ''
}
async function handleSavePriceAdjust() {
if (!priceAdjustListing.value) return
const payload =
priceAdjustMode.value === 'ratio'
? {
buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0),
reason: priceAdjustForm.reason.trim(),
}
: {
buyer_total_price_cent: yuanToCent(Number(priceAdjustForm.buyer_total_price || 0)),
reason: priceAdjustForm.reason.trim(),
}
if ((payload.buyer_ratio || payload.buyer_total_price_cent || 0) <= 0) {
ElMessage.warning('请填写有效的加价后比例或价格')
return
}
adjustingPrice.value = true
try {
const updated = await adjustListingReviewPrice(priceAdjustListing.value.id, payload)
replaceListing(updated)
priceAdjustListing.value = null
ElMessage.success('价格已更新')
} catch (error) {
ElMessage.error(readError(error, '价格调整失败'))
} finally {
adjustingPrice.value = false
}
}
function appendRejectReason(reason: string) {
const current = rejectReason.value.trim()
if (!current) {
rejectReason.value = reason
return
}
if (!current.includes(reason)) {
rejectReason.value = `${current}${reason}`
}
}
async function handleReject() {
if (!activeListing.value) return
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写拒绝原因')
return
}
submitting.value = true
try {
await rejectListing(activeListing.value.id, reason)
ElMessage.success('已拒绝发布并通知号主')
activeListing.value = null
rejectReason.value = ''
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '拒绝失败'))
} finally {
submitting.value = false
}
}
function openEvidence(row: Listing) {
evidenceListing.value = row
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 previewMoney(value: number) {
return value > 0 ? money(value) : '--'
}
function roundPreviewMoney(value: number) {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.round(value * 10) / 10
}
function roundPreviewRatio(value: number) {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.round(value * 10) / 10
}
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 calculatePriceAdjustPreview(
row: Listing,
mode: 'ratio' | 'price' = priceAdjustMode.value
) {
const consumablePrice = getListingConsumablePrice(row)
const coinWan = getCoinWan(row)
if (mode === 'price') {
const buyerTotalPrice = roundPreviewMoney(Number(priceAdjustForm.buyer_total_price || 0))
const buyerCoinBasePrice = roundPreviewMoney(buyerTotalPrice - consumablePrice)
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
}
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 0
const buyerTotalPrice =
buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
}
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 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 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 riskItems(row: Listing): RiskItem[] {
const items: RiskItem[] = []
if (isExternalUpload(row)) {
items.push({
label: `外部上传:${uploaderName(row)} / 渠道:${sourceChannel(row)}`,
level: 'info',
})
} else {
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'info' })
}
const infoCount = items.length
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
if (
getListingConsumablePrice(row) > 0 &&
centToYuan(row.deposit_amount_cent) <= getListingConsumablePrice(row)
) {
items.push({ label: '押金不高于消耗品价值', level: 'danger' })
}
if (readAssetNumber(row, 'daily_loss_m') <= 0)
items.push({ label: '缺少每日损耗', level: 'warning' })
if (readAssetNumber(row, 'fire_level') <= 40)
items.push({ label: '烽火等级接近下限', level: 'warning' })
if (!readAssetString(row, 'season_insurance'))
items.push({ label: '缺少保险格数', level: 'warning' })
if (items.length === infoCount) items.push({ label: '未发现明显风险', level: 'info' })
return items
}
function riskTagType(level: RiskLevel) {
if (level === 'danger') return 'danger'
if (level === 'warning') return 'warning'
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
}
}
}
async function openScreenshot(url: string) {
if (isDefaultScreenshot(url)) {
window.open(url, '_blank')
return
}
try {
const key = extractObjectKey(url)
if (!key) {
window.open(url, '_blank')
return
}
const blob = await fetchAdminFileBlob(key)
window.open(URL.createObjectURL(blob), '_blank')
} catch {
window.open(url, '_blank')
}
}
</script>
<template>
<section class="page review-page">
<div class="page-header-row review-header">
<div class="page-header">
<p class="eyebrow">Review</p>
<h1>商品审核</h1>
<p>集中核对账号资产上传来源价格押金和截图风险</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadListings">刷新</el-button>
</div>
</div>
<div class="review-summary">
<button
class="summary-card"
:class="{ active: filters.risk === 'all' }"
@click="filters.risk = 'all'"
>
<span>待审核</span>
<strong>{{ listings.length }}</strong>
</button>
<button
class="summary-card"
:class="{ active: filters.risk === 'external' }"
@click="filters.risk = 'external'"
>
<span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
</button>
<button
class="summary-card"
:class="{ active: filters.risk === 'defaultImage' }"
@click="filters.risk = 'defaultImage'"
>
<span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</button>
<button
class="summary-card"
:class="{ active: filters.risk === 'ban' }"
@click="filters.risk = 'ban'"
>
<span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
</button>
</div>
<div class="review-workbench">
<aside class="review-queue">
<div class="queue-toolbar">
<el-input
v-model="filters.keyword"
:prefix-icon="Search"
clearable
placeholder="搜索编号、标题、客服、渠道、段位、皮肤"
/>
<el-select v-model="filters.risk" placeholder="风险筛选">
<el-option label="全部待审" value="all" />
<el-option label="外部上传" value="external" />
<el-option label="默认截图" value="defaultImage" />
<el-option label="有封禁记录" value="ban" />
</el-select>
</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 }"
type="button"
@click="selectListing(item)"
>
<div class="queue-title-row">
<div>
<span class="queue-code">编号 {{ formatListingCode(item) }}</span>
<strong>{{ item.title }}</strong>
</div>
<span>{{ formatHafCoinM(getCoinWan(item)) }}</span>
</div>
<div class="queue-meta">
<span>{{ item.rank_level || '-' }}</span>
<span>{{ assetText(item, 'season_insurance') }}</span>
<span
>{{ assetText(item, 'stamina_level') }}/{{ assetText(item, 'load_level') }}</span
>
<span>KD {{ assetNumberText(item, 'secret_kd') }}</span>
</div>
<div class="queue-footer">
<span
>¥{{ formatCent(item.price_cent) }} / ¥{{
formatCent(item.deposit_amount_cent)
}}</span
>
<div class="queue-tags">
<el-tag size="small" type="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="hasDefaultScreenshot(item)" size="small" type="warning"
>默认图</el-tag
>
</div>
</div>
</button>
<el-empty
v-if="!loading && !filteredListings.length"
description="暂无符合条件的待审核商品"
/>
</div>
</aside>
<main v-if="selectedListing" class="review-detail">
<section class="review-hero-panel">
<div class="hero-title">
<div>
<p>商品编号 {{ formatListingCode(selectedListing) }}</p>
<h2>{{ selectedListing.title }}</h2>
</div>
<div class="hero-actions">
<RouterLink :to="adminPath(`listings/${selectedListing.id}`)">
<el-button>详情页</el-button>
</RouterLink>
<el-button
:icon="Close"
type="danger"
:loading="submitting"
@click="openReject(selectedListing)"
>拒绝</el-button
>
<el-button
:icon="Check"
type="primary"
:loading="submitting"
@click="handleApprove(selectedListing)"
>通过</el-button
>
</div>
</div>
<div class="risk-strip">
<el-tag
v-for="risk in activeRisks"
:key="risk.label"
:type="riskTagType(risk.level)"
effect="light"
>
{{ risk.label }}
</el-tag>
</div>
</section>
<section class="review-panel price-review-panel">
<div class="panel-title price-panel-title">
<div>
<h3>价格审核</h3>
<p>核对卖家提交价格平台加价规则和最终买家展示价</p>
</div>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)"
>调整价格</el-button
>
</div>
<div class="price-decision-grid">
<div class="price-decision-card seller">
<span>卖家发布</span>
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
<small
>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品
{{ money(getListingConsumablePrice(selectedListing)) }}</small
>
</div>
<div class="price-decision-card rule">
<span>当前加价规则</span>
<strong>{{ priceRuleLabel(selectedListing) }}</strong>
<p>{{ priceRuleText(selectedListing) }}</p>
<small>平台加价 {{ money(platformMarkupAmount(selectedListing)) }}</small>
</div>
<div class="price-decision-card buyer">
<span>加价后展示</span>
<strong>{{ money(buyerTotalPrice(selectedListing)) }}</strong>
<p>加价后比例 {{ ratioText(buyerRatio(selectedListing)) }}</p>
<small>纯币 {{ money(buyerCoinBasePrice(selectedListing)) }} / 总价含物品</small>
</div>
<div class="price-decision-card deposit">
<span>押金与损耗</span>
<strong>¥{{ formatCent(selectedListing.deposit_amount_cent) }}</strong>
<p>每日损耗 {{ dailyLossText(selectedListing) }}</p>
<small>哈夫币 {{ formatHafCoinM(getCoinWan(selectedListing)) }}</small>
</div>
</div>
</section>
<section class="review-grid">
<div class="review-panel">
<div class="panel-title">
<h3>账号属性</h3>
</div>
<dl class="detail-list">
<div>
<dt>上号方式</dt>
<dd>{{ selectedListing.login_platform || '-' }}</dd>
</div>
<div>
<dt>区服</dt>
<dd>{{ selectedListing.server_region || '-' }}</dd>
</div>
<div>
<dt>段位</dt>
<dd>{{ selectedListing.rank_level || '-' }}</dd>
</div>
<div>
<dt>烽火等级</dt>
<dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd>
</div>
<div>
<dt>保险格数</dt>
<dd>{{ assetText(selectedListing, 'season_insurance') }}</dd>
</div>
<div>
<dt>绝密KD</dt>
<dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd>
</div>
<div>
<dt>体力/负重</dt>
<dd>
{{ assetText(selectedListing, 'stamina_level') }} /
{{ assetText(selectedListing, 'load_level') }}
</dd>
</div>
<div>
<dt>封禁记录</dt>
<dd>{{ banRecordText(selectedListing) }}</dd>
</div>
</dl>
</div>
<div class="review-panel">
<div class="panel-title">
<h3>上传信息</h3>
<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>
<dl class="detail-list">
<div>
<dt>来源渠道</dt>
<dd>{{ sourceChannel(selectedListing) }}</dd>
</div>
<div>
<dt>上传人</dt>
<dd>{{ uploaderName(selectedListing) }}</dd>
</div>
<div>
<dt>号主</dt>
<dd>
{{
selectedListing.owner_phone ||
selectedListing.owner_nickname ||
selectedListing.owner_id
}}
</dd>
</div>
<div>
<dt>联系电话</dt>
<dd>{{ contactPhone(selectedListing) }}</dd>
</div>
<div>
<dt>常用地区</dt>
<dd>{{ commonRegionText(selectedListing) }}</dd>
</div>
<div>
<dt>在线时间</dt>
<dd>{{ ownerOnlineText(selectedListing) }}</dd>
</div>
<div>
<dt>提交时间</dt>
<dd>{{ formatDateTime(selectedListing.updated_at) }}</dd>
</div>
</dl>
</div>
</section>
<section class="review-panel">
<div class="panel-title">
<h3>库存资产</h3>
<span>额外消耗品约 {{ money(getListingConsumablePrice(selectedListing)) }}</span>
</div>
<div class="inventory-grid">
<div>
<span>AWM子弹</span
><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong>
</div>
<div>
<span>6</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong>
</div>
<div>
<span>6</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong>
</div>
</div>
<div v-if="selectedResources.length" class="resource-table">
<div v-for="resource in selectedResources" :key="resource.key">
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>{{ resource.mode }} · {{ resource.price }}</em>
</div>
</div>
<div class="skin-list">
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{
skin
}}</el-tag>
<span v-if="!selectedSkins.length">暂无皮肤数据</span>
</div>
</section>
<section class="review-panel">
<div class="panel-title">
<h3>账号截图</h3>
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)"
>查看全部</el-button
>
</div>
<div v-if="selectedListing.screenshot_urls?.length" class="screenshot-grid">
<button
v-for="url in selectedListing.screenshot_urls"
:key="url"
type="button"
class="screenshot-tile"
@click="openScreenshot(url)"
>
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
</div>
<div v-else class="empty-warning">
<el-icon><WarningFilled /></el-icon>
<span>暂无截图</span>
</div>
</section>
</main>
<main v-else class="review-detail empty-detail">
<el-empty description="暂无待审核商品" />
</main>
</div>
<el-dialog
:model-value="!!activeListing"
title="拒绝发布"
width="620px"
@update:model-value="activeListing = null"
>
<div v-if="activeListing" class="dialog-body reject-dialog">
<p>
<strong>{{ activeListing.title }}</strong>
</p>
<div class="reject-reasons">
<el-button
v-for="reason in rejectReasonOptions"
:key="reason"
size="small"
@click="appendRejectReason(reason)"
>
{{ reason }}
</el-button>
</div>
<el-input
v-model="rejectReason"
type="textarea"
:rows="5"
placeholder="填写拒绝原因,号主会在通知中看到审核结果"
/>
</div>
<template #footer>
<el-button @click="activeListing = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleReject">确认拒绝</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="!!priceAdjustListing"
title="调整审核价格"
width="560px"
@update:model-value="priceAdjustListing = null"
>
<div v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
<div class="adjust-current">
<div>
<span>卖家发布价</span>
<strong>{{ money(sellerTotalPrice(priceAdjustListing)) }}</strong>
</div>
<div>
<span>调整后买家价</span>
<strong class="preview-value">{{
previewMoney(priceAdjustPreview?.buyerTotalPrice || 0)
}}</strong>
</div>
<div>
<span>调整后买家比例</span>
<strong class="preview-value">{{
ratioText(priceAdjustPreview?.buyerRatio || 0)
}}</strong>
</div>
</div>
<el-radio-group v-model="priceAdjustMode" class="adjust-mode">
<el-radio-button label="ratio">按加价后比例</el-radio-button>
<el-radio-button label="price">按加价后价格</el-radio-button>
</el-radio-group>
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
<span>加价后比例</span>
<el-input-number
v-model="priceAdjustForm.buyer_ratio"
:min="0"
:step="0.1"
:controls="false"
/>
</label>
<label v-else class="adjust-field">
<span>加价后价格</span>
<el-input-number
v-model="priceAdjustForm.buyer_total_price"
:min="0"
:step="1"
:controls="false"
/>
</label>
<label class="adjust-field">
<span>调价原因</span>
<el-input
v-model="priceAdjustForm.reason"
type="textarea"
:rows="3"
placeholder="可填写调价原因,便于审计追踪"
/>
</label>
</div>
<template #footer>
<el-button @click="priceAdjustListing = null">取消</el-button>
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust"
>保存调整</el-button
>
</template>
</el-dialog>
<el-dialog
:model-value="!!evidenceListing"
title="账号资产截图"
width="860px"
@update:model-value="evidenceListing = null"
>
<div v-if="evidenceListing" class="dialog-body">
<p>
<strong>{{ evidenceListing.title }}</strong>
</p>
<div
v-if="evidenceListing.screenshot_urls?.length"
class="screenshot-grid dialog-screenshots"
>
<button
v-for="url in evidenceListing.screenshot_urls"
:key="url"
type="button"
class="screenshot-tile"
@click="openScreenshot(url)"
>
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
</div>
<p v-else>暂无截图</p>
</div>
<template #footer>
<el-button @click="evidenceListing = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.review-page {
min-height: calc(100vh - 128px);
}
.review-header {
margin-bottom: 18px;
}
.review-summary {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.summary-card {
min-width: 0;
border: 1px solid #e8edf4;
border-radius: 8px;
background: #ffffff;
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.summary-card:hover {
border-color: #1477ff;
box-shadow: 0 2px 8px rgba(20, 119, 255, 0.15);
transform: translateY(-1px);
}
.summary-card.active {
border-color: #1477ff;
background: #1477ff;
box-shadow: 0 2px 8px rgba(20, 119, 255, 0.25);
}
.summary-card.active span {
color: #ffffff;
}
.summary-card.active strong {
color: #ffffff;
}
.review-summary span,
.queue-footer span,
.panel-title span,
.inventory-grid span,
.resource-table span,
.price-decision-card span,
.adjust-current span,
.adjust-field span {
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.review-summary strong {
display: block;
margin-top: 5px;
color: #1b2559;
font-size: 24px;
font-weight: 800;
}
.review-workbench {
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.review-queue,
.review-detail,
.review-panel,
.review-hero-panel {
min-width: 0;
}
.review-queue {
position: sticky;
top: 16px;
border-radius: 8px;
background: #ffffff;
padding: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.queue-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.queue-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 300px);
overflow: auto;
padding-right: 2px;
}
.queue-item {
width: 100%;
min-height: 112px;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #ffffff;
padding: 12px;
text-align: left;
cursor: pointer;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
.queue-item:hover,
.queue-item.active {
border-color: #4f7cff;
background: #f8faff;
box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1);
}
.queue-title-row,
.queue-footer,
.panel-title,
.hero-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.queue-title-row strong {
display: block;
min-width: 0;
color: #1b2559;
font-size: 14px;
font-weight: 800;
line-height: 1.4;
}
.queue-title-row span {
flex-shrink: 0;
color: #4f7cff;
font-weight: 800;
}
.queue-title-row .queue-code {
display: inline-flex;
margin-bottom: 4px;
padding: 2px 7px;
border-radius: 999px;
background: #eff6ff;
font-size: 12px;
}
.queue-meta,
.risk-strip,
.skin-list,
.reject-reasons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.queue-meta {
margin: 10px 0;
}
.queue-meta span {
border-radius: 6px;
background: #f0f2f5;
padding: 4px 7px;
color: #4b5563;
font-size: 12px;
font-weight: 700;
}
.queue-footer {
align-items: center;
}
.queue-tags,
.panel-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.review-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.review-hero-panel,
.review-panel {
border-radius: 8px;
background: #ffffff;
padding: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.hero-title h2,
.panel-title h3 {
margin: 0;
color: #1b2559;
font-size: 18px;
font-weight: 800;
line-height: 1.35;
}
.hero-title p {
margin: 0 0 6px;
color: #8f9bba;
font-size: 12px;
font-weight: 700;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.risk-strip {
margin-top: 14px;
}
.review-metrics {
grid-template-columns: repeat(5, minmax(132px, 1fr));
gap: 12px;
margin-bottom: 0;
}
.review-metrics .metric-card {
border-radius: 8px;
padding: 16px;
}
.review-metrics .metric-card strong {
font-size: 20px;
}
.review-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.panel-title {
align-items: center;
margin-bottom: 14px;
}
.price-panel-title p {
margin: 5px 0 0;
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.price-decision-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 12px;
}
.price-decision-card {
display: grid;
gap: 7px;
min-width: 0;
min-height: 128px;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #f8f9fe;
padding: 14px;
}
.price-decision-card strong {
color: #1b2559;
font-size: 24px;
font-weight: 900;
line-height: 1.1;
}
.price-decision-card p,
.price-decision-card small {
margin: 0;
color: #4b5563;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.price-decision-card small {
color: #8f9bba;
}
.price-decision-card.buyer {
border-color: #bfdbfe;
background: #eff6ff;
}
.price-decision-card.buyer strong {
color: #2563eb;
}
.price-decision-card.rule {
border-color: #fed7aa;
background: #fff7ed;
}
.price-decision-card.rule strong {
color: #ea580c;
}
.price-decision-card.deposit {
border-color: #e0e7ff;
background: #f5f7ff;
}
.detail-list {
display: grid;
gap: 10px;
margin: 0;
}
.detail-list div {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.detail-list dt {
color: #8f9bba;
font-size: 12px;
font-weight: 700;
}
.detail-list dd {
min-width: 0;
margin: 0;
color: #1f2937;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.inventory-grid div {
border-radius: 8px;
background: #f8f9fe;
padding: 12px;
}
.inventory-grid strong {
display: block;
margin-top: 4px;
color: #1b2559;
font-size: 22px;
font-weight: 900;
}
.resource-table {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.resource-table div {
display: grid;
grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr);
gap: 10px;
align-items: center;
border-bottom: 1px solid #f0f2f5;
padding-bottom: 8px;
}
.resource-table strong {
color: #1f2937;
font-size: 13px;
font-weight: 800;
}
.resource-table em {
color: #8f9bba;
font-size: 12px;
font-style: normal;
font-weight: 600;
}
.skin-list {
align-items: center;
}
.skin-list > span {
color: #8f9bba;
font-size: 13px;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.screenshot-tile {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #f8f9fe;
padding: 0;
cursor: pointer;
}
.screenshot-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.screenshot-tile span {
position: absolute;
top: 8px;
left: 8px;
border-radius: 6px;
background: rgba(245, 158, 11, 0.95);
padding: 3px 7px;
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.empty-warning {
display: flex;
align-items: center;
gap: 8px;
min-height: 84px;
border-radius: 8px;
background: #fff7ed;
padding: 16px;
color: #c2410c;
font-weight: 700;
}
.dialog-screenshots {
max-height: 540px;
overflow: auto;
}
.reject-dialog p {
margin-top: 0;
}
.reject-reasons {
margin: 12px 0;
}
.price-adjust-dialog {
display: grid;
gap: 14px;
}
.adjust-current {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.adjust-current div {
display: grid;
gap: 5px;
border-radius: 8px;
background: #f8f9fe;
padding: 12px;
}
.adjust-current strong {
color: #1b2559;
font-size: 18px;
font-weight: 900;
}
.adjust-current .preview-value {
color: #2563eb;
}
.adjust-mode {
width: 100%;
}
.adjust-mode :deep(.el-radio-button) {
width: 50%;
}
.adjust-mode :deep(.el-radio-button__inner) {
width: 100%;
border-color: #d7deea;
font-weight: 800;
}
.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
border-color: #2563eb;
background: #2563eb;
box-shadow: -1px 0 0 0 #2563eb;
}
.adjust-field {
display: grid;
gap: 8px;
}
.adjust-field > span {
color: #52607a;
font-size: 12px;
font-weight: 800;
}
.adjust-field :deep(.el-input-number) {
width: 100%;
}
.empty-detail {
min-height: 420px;
display: grid;
place-items: center;
border-radius: 8px;
background: #ffffff;
}
@media (max-width: 1280px) {
.review-workbench {
grid-template-columns: 1fr;
}
.review-queue {
position: static;
}
.queue-list {
max-height: none;
}
}
@media (max-width: 900px) {
.review-summary,
.price-decision-grid,
.review-grid {
grid-template-columns: 1fr;
}
.adjust-current {
grid-template-columns: 1fr;
}
.hero-title {
flex-direction: column;
}
.hero-actions {
justify-content: flex-start;
}
.queue-toolbar {
grid-template-columns: 1fr;
}
}
</style>