优化商品审核界面
This commit is contained in:
@@ -1,30 +1,105 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
import { Check, Close, Picture, Refresh, Search, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import { onMounted, ref } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { fetchAdminFileBlob } from '@/api/files'
|
import { fetchAdminFileBlob } from '@/api/files'
|
||||||
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
|
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
|
||||||
|
import {
|
||||||
|
assetRegions,
|
||||||
|
formatHafCoinM,
|
||||||
|
formatRatio,
|
||||||
|
getCoinWan,
|
||||||
|
getListingConsumablePrice,
|
||||||
|
getListingResources,
|
||||||
|
getResourceQuantity,
|
||||||
|
getSkinNames,
|
||||||
|
readAssetNumber,
|
||||||
|
readAssetString,
|
||||||
|
} from '@/utils/listingDisplay'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
|
type RiskLevel = 'danger' | 'warning' | 'info'
|
||||||
|
|
||||||
|
interface RiskItem {
|
||||||
|
label: string
|
||||||
|
level: RiskLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
|
||||||
|
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常']
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const listings = ref<Listing[]>([])
|
const listings = ref<Listing[]>([])
|
||||||
|
const selectedID = ref<number | null>(null)
|
||||||
const activeListing = ref<Listing | null>(null)
|
const activeListing = ref<Listing | null>(null)
|
||||||
const evidenceListing = ref<Listing | null>(null)
|
const evidenceListing = ref<Listing | null>(null)
|
||||||
const rejectReason = ref('')
|
const rejectReason = ref('')
|
||||||
|
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) : []))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedListing.value,
|
||||||
|
async (listing) => {
|
||||||
|
if (!listing) return
|
||||||
|
selectedID.value = listing.id
|
||||||
|
await nextTick()
|
||||||
|
loadScreenshotPreviews(listing)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(loadListings)
|
onMounted(loadListings)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
})
|
||||||
|
|
||||||
async function loadListings() {
|
async function loadListings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
listings.value = await fetchPendingReviewListings()
|
listings.value = await fetchPendingReviewListings()
|
||||||
|
if (!selectedID.value || !listings.value.some((item) => item.id === selectedID.value)) {
|
||||||
|
selectedID.value = listings.value[0]?.id || null
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectListing(row: Listing) {
|
||||||
|
selectedID.value = row.id
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApprove(row: Listing) {
|
async function handleApprove(row: Listing) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认通过「${row.title}」并上架?`, '审核通过确认', {
|
||||||
|
confirmButtonText: '通过并上架',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await approveListing(row.id)
|
await approveListing(row.id)
|
||||||
@@ -42,11 +117,27 @@ function openReject(row: Listing) {
|
|||||||
rejectReason.value = row.review_reason || ''
|
rejectReason.value = row.review_reason || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function handleReject() {
|
||||||
if (!activeListing.value) return
|
if (!activeListing.value) return
|
||||||
|
const reason = rejectReason.value.trim()
|
||||||
|
if (!reason) {
|
||||||
|
ElMessage.warning('请填写拒绝原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await rejectListing(activeListing.value.id, rejectReason.value)
|
await rejectListing(activeListing.value.id, reason)
|
||||||
ElMessage.success('已拒绝发布并通知号主')
|
ElMessage.success('已拒绝发布并通知号主')
|
||||||
activeListing.value = null
|
activeListing.value = null
|
||||||
rejectReason.value = ''
|
rejectReason.value = ''
|
||||||
@@ -60,24 +151,161 @@ async function handleReject() {
|
|||||||
|
|
||||||
function openEvidence(row: Listing) {
|
function openEvidence(row: Listing) {
|
||||||
evidenceListing.value = row
|
evidenceListing.value = row
|
||||||
|
loadScreenshotPreviews(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function listingPrice(row: Listing) {
|
function money(value: number) {
|
||||||
return `¥${Math.round(Number(row.price || 0))}`
|
return `¥${Math.round(Number(value || 0))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 ownerOnlineText(row: Listing) {
|
||||||
|
const text = row.asset_summary?.online_time_text
|
||||||
|
if (typeof text === 'string' && text.trim()) return text
|
||||||
|
const onlineTime = row.asset_summary?.online_time
|
||||||
|
if (typeof onlineTime !== 'object' || onlineTime === null) return '-'
|
||||||
|
const start = (onlineTime as Record<string, unknown>).start
|
||||||
|
const end = (onlineTime as Record<string, unknown>).end
|
||||||
|
if (typeof start === 'string' && typeof end === 'string' && start && end) return `${start}-${end}`
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
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(defaultScreenshotURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskItems(row: Listing): RiskItem[] {
|
||||||
|
const items: RiskItem[] = []
|
||||||
|
if (isExternalUpload(row)) items.push({ label: `外部上传:${uploaderName(row)}`, level: 'info' })
|
||||||
|
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 && Number(row.deposit_amount || 0) <= 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) 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.title,
|
||||||
|
row.owner_phone,
|
||||||
|
row.owner_nickname,
|
||||||
|
row.owner_id,
|
||||||
|
row.server_region,
|
||||||
|
row.login_platform,
|
||||||
|
row.rank_level,
|
||||||
|
uploaderName(row),
|
||||||
|
getSkinNames(row).join(' '),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractObjectKey(url: string) {
|
function extractObjectKey(url: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url, window.location.origin)
|
const parsed = new URL(url, window.location.origin)
|
||||||
return parsed.searchParams.get('key') || url
|
return parsed.searchParams.get('key') || ''
|
||||||
} catch {
|
} catch {
|
||||||
return url
|
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) {
|
async function openScreenshot(url: string) {
|
||||||
|
if (isDefaultScreenshot(url)) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const blob = await fetchAdminFileBlob(extractObjectKey(url))
|
const key = extractObjectKey(url)
|
||||||
|
if (!key) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const blob = await fetchAdminFileBlob(key)
|
||||||
window.open(URL.createObjectURL(blob), '_blank')
|
window.open(URL.createObjectURL(blob), '_blank')
|
||||||
} catch {
|
} catch {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
@@ -94,49 +322,211 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page review-page">
|
||||||
<div class="page-header-row">
|
<div class="page-header-row review-header">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Review</p>
|
<p class="eyebrow">Review</p>
|
||||||
<h1>商品审核</h1>
|
<h1>商品审核</h1>
|
||||||
<p>审核号主提交的账号资产、价格和押金。</p>
|
<p>集中核对账号资产、上传来源、价格、押金和截图风险。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="loadListings">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="loadListings">刷新</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
<div class="review-summary">
|
||||||
<el-table-column prop="title" label="标题" min-width="180" />
|
<div>
|
||||||
<el-table-column prop="owner_id" label="号主" width="90" />
|
<span>待审核</span>
|
||||||
<el-table-column prop="server_region" label="区服" width="120" />
|
<strong>{{ listings.length }}</strong>
|
||||||
<el-table-column prop="login_platform" label="平台" width="120" />
|
</div>
|
||||||
<el-table-column prop="haf_coin_amount" label="哈夫币" width="110" />
|
<div>
|
||||||
<el-table-column prop="rank_level" label="段位" width="110" />
|
<span>外部上传</span>
|
||||||
<el-table-column label="价格" width="90">
|
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
|
||||||
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
</div>
|
||||||
</el-table-column>
|
<div>
|
||||||
<el-table-column prop="deposit_amount" label="押金" width="90" />
|
<span>默认截图</span>
|
||||||
<el-table-column label="截图" width="90">
|
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
|
||||||
<template #default="{ row }">
|
</div>
|
||||||
<el-button size="small" :disabled="!row.screenshot_urls?.length" @click="openEvidence(row)">
|
<div>
|
||||||
{{ row.screenshot_urls?.length || 0 }} 个
|
<span>封禁风险</span>
|
||||||
</el-button>
|
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
</div>
|
||||||
<el-table-column label="提交时间" min-width="180">
|
|
||||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="170">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="primary" :loading="submitting" @click="handleApprove(row)">通过</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
|
<div class="review-workbench">
|
||||||
<div v-if="activeListing" class="dialog-body">
|
<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">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<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>{{ money(item.price) }} / 押 {{ money(item.deposit_amount) }}</span>
|
||||||
|
<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>
|
||||||
|
</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>商品 #{{ selectedListing.id }}</p>
|
||||||
|
<h2>{{ selectedListing.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<RouterLink :to="`/admin/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="metric-grid review-metrics">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>哈夫币</span>
|
||||||
|
<strong>{{ formatHafCoinM(getCoinWan(selectedListing)) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>回收比例</span>
|
||||||
|
<strong>{{ formatRatio(selectedListing) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>回收租金</span>
|
||||||
|
<strong>{{ money(selectedListing.price) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>押金</span>
|
||||||
|
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>每日损耗</span>
|
||||||
|
<strong>{{ dailyLossText(selectedListing) }}</strong>
|
||||||
|
</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>
|
||||||
|
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info">外部上传</el-tag>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<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>
|
<p><strong>{{ activeListing.title }}</strong></p>
|
||||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
|
<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>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="activeListing = null">取消</el-button>
|
<el-button @click="activeListing = null">取消</el-button>
|
||||||
@@ -144,14 +534,14 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="680px" @update:model-value="evidenceListing = null">
|
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
|
||||||
<div v-if="evidenceListing" class="dialog-body">
|
<div v-if="evidenceListing" class="dialog-body">
|
||||||
<p><strong>{{ evidenceListing.title }}</strong></p>
|
<p><strong>{{ evidenceListing.title }}</strong></p>
|
||||||
<div v-if="evidenceListing.screenshot_urls?.length" class="evidence-list">
|
<div v-if="evidenceListing.screenshot_urls?.length" class="screenshot-grid dialog-screenshots">
|
||||||
<div v-for="url in evidenceListing.screenshot_urls" :key="url" class="evidence-row">
|
<button v-for="url in evidenceListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
|
||||||
<span>{{ url }}</span>
|
<img :src="previewURLs[url] || url" alt="账号截图" />
|
||||||
<el-button size="small" @click="openScreenshot(url)">打开</el-button>
|
<span v-if="isDefaultScreenshot(url)">默认图片</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>暂无截图</p>
|
<p v-else>暂无截图</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,3 +551,413 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-summary div {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-summary span,
|
||||||
|
.queue-footer span,
|
||||||
|
.panel-title span,
|
||||||
|
.inventory-grid span,
|
||||||
|
.resource-table 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 {
|
||||||
|
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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
|
.review-metrics,
|
||||||
|
.review-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user