Files
hfb_sys/frontend/src/features/admin/views/AdminListingReviewView.vue
T
yml2213 e010f2ce7e chore: 批量更新导入路径到 features 架构
- 更新所有 admin API 文件导入路径
- 更新所有 features 中 Vue 文件的导入路径
- 修复 ChatAttachmentImage 组件导入
- 修复 auth 模块部分导入路径

剩余 ~190 个类型错误待修复(主要是缺失的模块)
2026-06-04 09:23:15 +08:00

964 lines
26 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 { 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 { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
import {
assetRegions,
formatHafCoinM,
formatRatio,
getCoinWan,
getListingConsumablePrice,
getListingResources,
getResourceQuantity,
getSkinNames,
readAssetNumber,
readAssetString,
} from '@/utils/listingDisplay'
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 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 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)
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
}
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 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 ${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) {
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')
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</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">
<div>
<span>待审核</span>
<strong>{{ listings.length }}</strong>
</div>
<div>
<span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
</div>
<div>
<span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</div>
<div>
<span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
</div>
</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">
<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>
<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="!!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;
}
.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>