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

1031 lines
35 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 { CopyDocument, EditPen, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
import { fetchAdminFileBlob } from '@/shared/api/files'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { adminPath, isAdminPath } from '@/shared/utils/adminPath'
import {
centToYuan,
formatCentWithSymbol,
formatMoneyWithSymbol,
roundMoney,
yuanToCent,
} from '@/shared/utils/money'
import {
adjustExternalListing,
adminMarkListingAbnormal,
adminOfflineListing,
adminTransferListingOwner,
emptyListingSalePriceConfig,
fetchAdminListing,
fetchListingSalePriceConfig,
type AdminExternalListingAdjustMode,
type PublishSalePriceConfig,
type Listing,
} from '@/features/listings'
import { useAdminSessionStore } from '@/stores/adminSession'
import {
listingReviewStatusLabel,
listingStatusLabel,
realnameStatusLabel,
userStatusLabel,
} from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatGameName,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingDisplayPrice,
getListingResources,
getListingSellerPrice,
getOnlineTimeText,
getResourceQuantity,
getSkinNames,
formatListingCode,
readAssetNumber,
readAssetString,
} from '@/shared/utils/listingDisplay'
import { calculatePlatformPricing, roundRatio as roundPricingRatio } from '@/shared/utils/pricing'
const adminSession = useAdminSessionStore()
const route = useRoute()
const router = useRouter()
// 返回列表:有站内后台历史则回退(保留列表页筛选与滚动恢复),否则直接回列表页。
function goBackToList() {
const back = (window.history.state as { back?: string | null } | null)?.back
if (back && isAdminPath(back)) {
router.back()
return
}
void router.push(adminPath('listings'))
}
const loading = ref(false)
const submitting = ref(false)
const userSearching = ref(false)
const listing = ref<Listing | null>(null)
const actionType = ref<'offline' | 'abnormal' | ''>('')
const reason = ref('')
const transferVisible = ref(false)
const transferReason = ref('')
const userKeyword = ref('')
const userOptions = ref<AdminUserItem[]>([])
const selectedUser = ref<AdminUserItem | null>(null)
const externalAdjustVisible = ref(false)
const externalAdjustSaving = ref(false)
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
const externalAdjustModes: Array<{ label: string; value: AdminExternalListingAdjustMode }> = [
{ label: '按降价金额', value: 'discount_amount' },
{ label: '按发布比例', value: 'sale_ratio' },
]
const externalAdjustForm = reactive({
haf_coin_m: 0,
awm_bullets: 0,
level6_helmets: 0,
level6_armor: 0,
deposit_yuan: 0,
adjust_mode: 'discount_amount' as AdminExternalListingAdjustMode,
discount_amount_yuan: 0,
sale_ratio: 0,
reason: '',
})
const actionTitle = computed(() =>
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
)
const canOperate = computed(
() =>
!!listing.value &&
listing.value.status !== 'rented' &&
!['offline', 'abnormal'].includes(listing.value.status)
)
const canTransfer = computed(() => !!listing.value && listing.value.status !== 'rented')
const canAdjustExternal = computed(
() => !!listing.value?.is_external_upload && adminSession.hasPermission('listing:edit_external')
)
const externalAdjustmentLocked = computed(
() =>
!listing.value ||
listing.value.in_transaction ||
!['draft', 'published', 'offline', 'abnormal'].includes(listing.value.status)
)
const selectedUserAvailable = computed(
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
)
const assetRows = computed(() => {
if (!listing.value) return []
return [
{ label: '商品编号', value: formatListingCode(listing.value) },
{ label: '账号 ID', value: listing.value.account_id },
{ label: '游戏', value: formatGameName(listing.value.game_name) },
{ label: '区服', value: listing.value.server_region || '-' },
{ label: '平台', value: listing.value.login_platform || '-' },
{ label: '段位', value: listing.value.rank_level || '-' },
{ label: '烽火等级', value: assetNumberText(listing.value, 'fire_level') },
{ label: '保险格数', value: assetText(listing.value, 'season_insurance') },
{
label: '体力/负重',
value: `${assetText(listing.value, 'stamina_level')} / ${assetText(listing.value, 'load_level')}`,
},
{ label: '绝密 KD', value: assetNumberText(listing.value, 'secret_kd') },
{ label: '常用地区', value: regionText(listing.value) },
{ label: '在线时间', value: getOnlineTimeText(listing.value) || '-' },
]
})
const ownerRows = computed(() => {
if (!listing.value) return []
const row = listing.value
const renterPrice = buyerTotalPrice(row)
const ownerCoinBase = sellerCoinBasePrice(row)
const ownerLoss = getListingConsumablePrice(row)
const ownerTotal = sellerTotalPrice(row)
const platformFee = platformMarkupAmount(row)
return [
{
label: '号主',
value: row.owner_phone || row.owner_nickname || row.owner_id,
},
{ label: '号主 ID', value: row.owner_id },
// 价格字段与订单详情「资金拆分」对齐,便于客服核对
{ label: '租客租金', value: moneyYuan(renterPrice) },
{ label: '号主纯币价格', value: moneyYuan(ownerCoinBase) },
{ label: '号主损耗', value: moneyYuan(ownerLoss) },
{ label: '号主租金合计', value: moneyYuan(ownerTotal) },
{ label: '平台费用', value: moneyYuan(platformFee) },
...(row.is_external_upload
? [
{ label: 'API参考比例', value: ratioText(externalReferenceRatio.value) },
{ label: '号主发布比例', value: ratioText(sellerSaleRatio(row)) },
{ label: '租客售卖比例', value: ratioText(buyerSaleRatio(row)) },
{ label: '价格标签', value: row.is_accelerated_sale ? '特惠' : '常规' },
]
: []),
{ label: '押金', value: moneyCent(row.deposit_amount_cent) },
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(row)) },
{ label: '每日损耗', value: getDailyLoss(row) || '-' },
{ label: '预计可租', value: formatEstimatedRentalDuration(row) },
]
})
const resourceCards = computed(() => {
if (!listing.value) return []
const core = [
{ key: 'awmAmmo', label: 'AWM 子弹', quantity: getResourceQuantity(listing.value, 'awmAmmo') },
{ key: 'helmet6', label: '六级头', quantity: getResourceQuantity(listing.value, 'helmet6') },
{ key: 'armor6', label: '六级甲', quantity: getResourceQuantity(listing.value, 'armor6') },
].filter(item => item.quantity > 0)
const extra = getListingResources(listing.value)
.filter(item => !core.some(coreItem => coreItem.key === item.key))
.slice(0, 6)
.map(item => ({ key: item.key, label: item.label, quantity: item.quantity }))
return [...core, ...extra]
})
const skinNames = computed(() => (listing.value ? getSkinNames(listing.value).slice(0, 16) : []))
const hiddenSkinCount = computed(() => {
if (!listing.value) return 0
return Math.max(0, getSkinNames(listing.value).length - skinNames.value.length)
})
const externalReferenceRatio = computed(() => {
if (!listing.value) return 0
return (
breakdownNumber(listing.value, 'seller_reference_ratio') ||
breakdownNumber(listing.value, 'seller_ratio')
)
})
const externalAdjustmentPreview = computed(() => {
const coinM = Math.max(Number(externalAdjustForm.haf_coin_m || 0), 0)
const coinWan = coinM * 100
const referenceRatio = externalReferenceRatio.value
const referenceCoinPrice =
coinWan > 0 && referenceRatio > 0 ? roundMoney(coinWan / referenceRatio) : 0
let saleRatio = roundPricingRatio(Number(externalAdjustForm.sale_ratio || 0))
if (externalAdjustForm.adjust_mode === 'discount_amount') {
const desiredPrice = referenceCoinPrice - Number(externalAdjustForm.discount_amount_yuan || 0)
saleRatio = desiredPrice > 0 ? roundPricingRatio(coinWan / desiredPrice) : 0
}
const sellerCoinPrice = saleRatio > 0 ? roundMoney(coinWan / saleRatio) : 0
const actualDiscountAmount = Math.max(0, roundMoney(referenceCoinPrice - sellerCoinPrice))
const consumablePrice = roundMoney(
normalizedQuantity(externalAdjustForm.awm_bullets) * 0.6 +
normalizedQuantity(externalAdjustForm.level6_helmets) * 1.5 +
normalizedQuantity(externalAdjustForm.level6_armor) * 2.5
)
const sellerTotalPrice = roundMoney(sellerCoinPrice + consumablePrice)
const platformPricing = calculatePlatformPricing({
coinMAmount: coinM,
coinWanAmount: coinWan,
sellerRatio: saleRatio,
sellerCoinBasePrice: sellerCoinPrice,
sellerTotalPrice,
consumablePrice,
salePriceConfig: salePriceConfig.value,
})
const referencePlatformPricing = calculatePlatformPricing({
coinMAmount: coinM,
coinWanAmount: coinWan,
sellerRatio: referenceRatio,
sellerCoinBasePrice: referenceCoinPrice,
sellerTotalPrice: roundMoney(referenceCoinPrice + consumablePrice),
consumablePrice,
salePriceConfig: salePriceConfig.value,
})
return {
referenceRatio,
maxSaleRatio: roundPricingRatio(referenceRatio + 10),
referenceCoinPrice,
saleRatio,
sellerCoinPrice,
sellerTotalPrice,
consumablePrice,
actualDiscountAmount,
buyerRatio: platformPricing.buyerRatio,
buyerTotalPrice: platformPricing.buyerTotalPrice,
buyerDiscountAmount: Math.max(
0,
roundMoney(referencePlatformPricing.buyerTotalPrice - platformPricing.buyerTotalPrice)
),
isSale: referenceRatio > 0 && saleRatio > referenceRatio,
}
})
const statusTone = computed(() => {
if (!listing.value) return 'info'
if (listing.value.status === 'published') return 'success'
if (['offline', 'abnormal'].includes(listing.value.status)) return 'danger'
if (listing.value.status === 'rented') return 'warning'
return 'info'
})
const reviewTone = computed(() => {
if (!listing.value) return 'info'
if (listing.value.review_status === 'approved') return 'success'
if (listing.value.review_status === 'rejected') return 'danger'
if (listing.value.review_status === 'pending') return 'warning'
return 'info'
})
onMounted(loadListing)
async function loadListing() {
loading.value = true
try {
listing.value = await fetchAdminListing(String(route.params.id))
} finally {
loading.value = false
}
}
async function openExternalAdjustment() {
if (!listing.value || !canAdjustExternal.value || externalAdjustmentLocked.value) return
const row = listing.value
const referenceRatio = externalReferenceRatio.value
const currentSaleRatio = sellerSaleRatio(row) || referenceRatio
const referenceCoinPrice = referenceRatio > 0 ? roundMoney(getCoinWan(row) / referenceRatio) : 0
const currentCoinPrice = sellerCoinBasePrice(row)
externalAdjustForm.haf_coin_m = Number((row.haf_coin_amount / 1000000).toFixed(1))
externalAdjustForm.awm_bullets = getResourceQuantity(row, 'awmAmmo')
externalAdjustForm.level6_helmets = getResourceQuantity(row, 'helmet6')
externalAdjustForm.level6_armor = getResourceQuantity(row, 'armor6')
externalAdjustForm.deposit_yuan = centToYuan(row.deposit_amount_cent)
externalAdjustForm.adjust_mode = 'discount_amount'
externalAdjustForm.discount_amount_yuan = Math.max(
0,
roundMoney(referenceCoinPrice - currentCoinPrice)
)
externalAdjustForm.sale_ratio = currentSaleRatio
externalAdjustForm.reason = ''
externalAdjustVisible.value = true
try {
salePriceConfig.value = await fetchListingSalePriceConfig()
} catch {
salePriceConfig.value = emptyListingSalePriceConfig
}
}
async function submitExternalAdjustment() {
if (!listing.value) return
const preview = externalAdjustmentPreview.value
if (externalAdjustForm.haf_coin_m <= 0) {
ElMessage.warning('请输入有效的纯币数量')
return
}
if (
[
externalAdjustForm.awm_bullets,
externalAdjustForm.level6_helmets,
externalAdjustForm.level6_armor,
].some(value => !Number.isInteger(Number(value)) || Number(value) < 0)
) {
ElMessage.warning('额外物品数量必须是非负整数')
return
}
if (
preview.saleRatio < preview.referenceRatio ||
preview.saleRatio > preview.maxSaleRatio ||
preview.sellerCoinPrice <= 0
) {
ElMessage.warning(
`发布比例应在 1:${ratioNumber(preview.referenceRatio)} 至 1:${ratioNumber(preview.maxSaleRatio)} 之间`
)
return
}
if (preview.consumablePrice > 0 && externalAdjustForm.deposit_yuan <= preview.consumablePrice) {
ElMessage.warning('押金必须大于额外物品总价值')
return
}
if (!externalAdjustForm.reason.trim()) {
ElMessage.warning('请填写调整原因')
return
}
externalAdjustSaving.value = true
try {
listing.value = await adjustExternalListing(listing.value.id, {
haf_coin_amount: Math.round(externalAdjustForm.haf_coin_m * 1000000),
awm_bullets: normalizedQuantity(externalAdjustForm.awm_bullets),
level6_helmets: normalizedQuantity(externalAdjustForm.level6_helmets),
level6_armor: normalizedQuantity(externalAdjustForm.level6_armor),
deposit_amount_cent: yuanToCent(externalAdjustForm.deposit_yuan),
adjust_mode: externalAdjustForm.adjust_mode,
discount_amount_cent:
externalAdjustForm.adjust_mode === 'discount_amount'
? yuanToCent(externalAdjustForm.discount_amount_yuan)
: undefined,
sale_ratio:
externalAdjustForm.adjust_mode === 'sale_ratio'
? Number(externalAdjustForm.sale_ratio)
: undefined,
reason: externalAdjustForm.reason.trim(),
})
externalAdjustVisible.value = false
ElMessage.success('外部商品已调整')
} catch (error) {
ElMessage.error(readError(error, '调整失败'))
} finally {
externalAdjustSaving.value = false
}
}
async function copyListingCode() {
if (!listing.value) return
try {
await navigator.clipboard.writeText(formatListingCode(listing.value))
ElMessage.success('商品编号已复制')
} catch {
ElMessage.error('复制失败')
}
}
function openAction(type: 'offline' | 'abnormal') {
actionType.value = type
reason.value = ''
}
function openTransfer() {
transferVisible.value = true
transferReason.value = ''
userKeyword.value = ''
selectedUser.value = null
userOptions.value = []
}
async function searchUsers() {
userSearching.value = true
try {
const result = await fetchAdminUsers(1, 8, {
keyword: userKeyword.value.trim(),
status: 'active',
})
userOptions.value = result.items.filter(user => user.realname_status === 'verified')
} catch (error) {
ElMessage.error(readError(error, '查询用户失败'))
} finally {
userSearching.value = false
}
}
function selectUser(user: AdminUserItem) {
selectedUser.value = user
}
async function submitAction() {
if (!listing.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'offline') {
listing.value = await adminOfflineListing(listing.value.id, reason.value)
ElMessage.success('商品已强制下架')
} else {
listing.value = await adminMarkListingAbnormal(listing.value.id, reason.value)
ElMessage.success('商品已标记异常')
}
actionType.value = ''
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
async function submitTransferOwner() {
if (!listing.value || !selectedUser.value) return
if (!selectedUserAvailable.value) {
ElMessage.warning('只能转移给正常且已实名的用户')
return
}
if (!transferReason.value.trim()) {
ElMessage.warning('请填写转移原因')
return
}
submitting.value = true
try {
listing.value = await adminTransferListingOwner(
listing.value.id,
selectedUser.value.id,
transferReason.value.trim()
)
ElMessage.success('所有权已转移')
transferVisible.value = false
} catch (error) {
ElMessage.error(readError(error, '转移失败'))
} finally {
submitting.value = false
}
}
function moneyCent(value: number) {
return formatCentWithSymbol(value)
}
function moneyYuan(value: number) {
return formatMoneyWithSymbol(value)
}
function listingPrice(row: Listing) {
return moneyYuan(buyerTotalPrice(row))
}
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 buyerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'buyer_total_price')
return value > 0 ? roundMoney(value) : roundMoney(getListingDisplayPrice(row))
}
/** 号主纯币基础价 */
function sellerCoinBasePrice(row: Listing) {
const value = breakdownNumber(row, 'seller_coin_base_price')
if (value > 0) return roundMoney(value)
return Math.max(0, roundMoney(sellerTotalPrice(row) - getListingConsumablePrice(row)))
}
/** 号主租金合计 = 纯币基础价 + 额外消耗 */
function sellerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'seller_total_price')
if (value > 0) return roundMoney(value)
return roundMoney(getListingSellerPrice(row))
}
/** 平台费用 = 租客价 - 号主价 */
function platformMarkupAmount(row: Listing) {
const value = breakdownNumber(row, 'platform_markup_amount')
if (value > 0) return roundMoney(value)
return Math.max(0, roundMoney(buyerTotalPrice(row) - sellerTotalPrice(row)))
}
function sellerSaleRatio(row: Listing) {
const value = breakdownNumber(row, 'seller_ratio')
if (value > 0) return value
const price = sellerCoinBasePrice(row)
return price > 0 ? roundPricingRatio(getCoinWan(row) / price) : 0
}
function buyerSaleRatio(row: Listing) {
const value = breakdownNumber(row, 'buyer_ratio')
if (value > 0) return value
const price = buyerTotalPrice(row) - getListingConsumablePrice(row)
return price > 0 ? roundPricingRatio(getCoinWan(row) / price) : 0
}
function ratioNumber(value: number) {
const rounded = roundPricingRatio(value)
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
function ratioText(value: number) {
return value > 0 ? `1:${ratioNumber(value)}` : '-'
}
function normalizedQuantity(value: number) {
const quantity = Number(value || 0)
return Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
}
function assetText(row: Listing, key: string) {
const value = readAssetString(row, key)
if (value) return value
const numberValue = readAssetNumber(row, key)
return numberValue > 0 ? String(numberValue) : '-'
}
function assetNumberText(row: Listing, key: string) {
const value = readAssetNumber(row, key)
if (value <= 0) return '-'
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
function regionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function openScreenshot(url: string) {
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" v-loading="loading">
<div v-if="listing" class="listing-detail-header">
<div class="listing-title-block">
<div class="listing-code-line">
<span>商品编号</span>
<strong>{{ formatListingCode(listing) }}</strong>
</div>
<h1>{{ listing.title || '商品详情' }}</h1>
<div class="listing-subtitle">
<span>{{ listing.server_region || '-' }}</span>
<span>{{ listing.rank_level || '-' }}</span>
<span>{{ listing.login_platform || '-' }}</span>
<span>账号 {{ listing.account_id }}</span>
<span v-if="listing.is_external_upload"
>外部 API · {{ listing.source_channel || '未填写' }}</span
>
</div>
</div>
<div class="toolbar-actions">
<el-button :icon="CopyDocument" @click="copyListingCode">复制编号</el-button>
<el-button @click="goBackToList">返回列表</el-button>
<el-button
v-if="canAdjustExternal"
type="primary"
:icon="EditPen"
:disabled="externalAdjustmentLocked"
@click="openExternalAdjustment"
>
调整商品
</el-button>
<el-button
class="transfer-owner-trigger"
type="success"
:disabled="!canTransfer"
@click="openTransfer"
>转移所有权</el-button
>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
>强制下架</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
</div>
</div>
<el-dialog
v-model="externalAdjustVisible"
title="调整外部商品"
width="760px"
class="external-adjust-dialog"
>
<el-form label-width="112px" class="external-adjust-form">
<div class="external-adjust-grid">
<el-form-item label="纯币数量(M)" required>
<el-input-number
v-model="externalAdjustForm.haf_coin_m"
:min="0.1"
:step="1"
:precision="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="押金(元)" required>
<el-input-number
v-model="externalAdjustForm.deposit_yuan"
:min="0"
:step="10"
:precision="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="AWM子弹">
<el-input-number
v-model="externalAdjustForm.awm_bullets"
:min="0"
:step="1"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="六级头">
<el-input-number
v-model="externalAdjustForm.level6_helmets"
:min="0"
:step="1"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="六级甲">
<el-input-number
v-model="externalAdjustForm.level6_armor"
:min="0"
:step="1"
:precision="0"
controls-position="right"
/>
</el-form-item>
</div>
<el-form-item label="调整方式" required>
<el-segmented v-model="externalAdjustForm.adjust_mode" :options="externalAdjustModes" />
</el-form-item>
<el-form-item
v-if="externalAdjustForm.adjust_mode === 'discount_amount'"
label="参考价降价(元)"
required
>
<el-input-number
v-model="externalAdjustForm.discount_amount_yuan"
:min="0"
:step="1"
:precision="1"
controls-position="right"
/>
</el-form-item>
<el-form-item v-else label="目标发布比例" required>
<el-input-number
v-model="externalAdjustForm.sale_ratio"
:min="externalAdjustmentPreview.referenceRatio"
:max="externalAdjustmentPreview.maxSaleRatio"
:step="0.1"
:precision="1"
controls-position="right"
/>
</el-form-item>
<div class="external-adjust-preview">
<div>
<span>API参考比例</span>
<strong>{{ ratioText(externalAdjustmentPreview.referenceRatio) }}</strong>
</div>
<div>
<span>调整后比例</span>
<strong>{{ ratioText(externalAdjustmentPreview.saleRatio) }}</strong>
</div>
<div>
<span>参考纯币价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.referenceCoinPrice) }}</strong>
</div>
<div>
<span>调整后纯币价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.sellerCoinPrice) }}</strong>
</div>
<div>
<span>号主实际让利</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.actualDiscountAmount) }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.consumablePrice) }}</strong>
</div>
<div>
<span>号主合计</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.sellerTotalPrice) }}</strong>
</div>
<div>
<span>租客售价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.buyerTotalPrice) }}</strong>
</div>
<div>
<span>租客实际降价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.buyerDiscountAmount) }}</strong>
</div>
<div>
<span>展示状态</span>
<el-tag :type="externalAdjustmentPreview.isSale ? 'danger' : 'info'" effect="light">
{{ externalAdjustmentPreview.isSale ? '特惠' : '常规' }}
</el-tag>
</div>
</div>
<el-form-item label="调整原因" required>
<el-input
v-model="externalAdjustForm.reason"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit
placeholder="填写用户申请内容或资产修正原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="externalAdjustVisible = false">取消</el-button>
<el-button type="primary" :loading="externalAdjustSaving" @click="submitExternalAdjustment">
保存调整
</el-button>
</template>
</el-dialog>
<div v-if="listing" class="listing-summary-bar">
<div>
<span>商品状态</span>
<el-tag :type="statusTone" effect="light">{{ listingStatusLabel(listing.status) }}</el-tag>
</div>
<div>
<span>审核状态</span>
<el-tag :type="reviewTone" effect="light">{{
listingReviewStatusLabel(listing.review_status)
}}</el-tag>
</div>
<div>
<span>租金</span>
<strong>{{ listingPrice(listing) }}</strong>
</div>
<div>
<span>押金</span>
<strong>{{ moneyCent(listing.deposit_amount_cent) }}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{{ formatHafCoinM(getCoinWan(listing)) }}</strong>
</div>
<div>
<span>预计可租</span>
<strong>{{ formatEstimatedRentalDuration(listing) }}</strong>
</div>
</div>
<div v-if="listing" class="listing-workspace">
<section class="detail-panel primary-panel">
<div class="panel-heading">
<h2>账号属性</h2>
<span>{{ formatGameName(listing.game_name, '三角洲') }}</span>
</div>
<dl class="compact-detail-grid">
<div v-for="row in assetRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="detail-panel side-panel">
<div class="panel-heading">
<h2>号主与价格</h2>
<span>{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</span>
</div>
<dl class="compact-detail-grid single">
<div v-for="row in ownerRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="detail-panel primary-panel">
<div class="panel-heading">
<h2>库存资产</h2>
<span
>{{ resourceCards.length }} 项资源 ·
{{ skinNames.length + hiddenSkinCount }} 个皮肤</span
>
</div>
<div v-if="resourceCards.length" class="resource-grid">
<div v-for="item in resourceCards" :key="item.key" class="resource-item">
<span>{{ item.label }}</span>
<strong>{{ item.quantity }}</strong>
</div>
</div>
<p v-else class="empty-line">暂无库存资源</p>
<div class="skin-chip-list">
<el-tag v-for="skin in skinNames" :key="skin" size="small" effect="plain">{{
skin
}}</el-tag>
<el-tag v-if="hiddenSkinCount" size="small" effect="plain">+{{ hiddenSkinCount }}</el-tag>
<span v-if="!skinNames.length" class="empty-line">暂无皮肤数据</span>
</div>
</section>
<section class="detail-panel side-panel">
<div class="panel-heading">
<h2>说明与时间</h2>
<span>{{ formatDateTime(listing.updated_at) }}</span>
</div>
<dl class="compact-detail-grid single">
<div>
<dt>商品说明</dt>
<dd>{{ listing.description || '暂无商品说明' }}</dd>
</div>
<div>
<dt>审核/后台原因</dt>
<dd>{{ listing.review_reason || '-' }}</dd>
</div>
<div>
<dt>上架时间</dt>
<dd>{{ formatDateTime(listing.published_at) }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ formatDateTime(listing.updated_at) }}</dd>
</div>
</dl>
</section>
<section class="detail-panel screenshots-panel">
<div class="panel-heading">
<h2>资产截图</h2>
<span>{{ listing.screenshot_urls?.length || 0 }} 张</span>
</div>
<div v-if="listing.screenshot_urls?.length" class="screenshot-grid">
<button
v-for="(url, index) in listing.screenshot_urls"
:key="url"
class="screenshot-thumb"
type="button"
@click="openScreenshot(url)"
>
<AuthImage
:source="url"
admin
:alt="`资产截图 ${index + 1}`"
image-class="screenshot-image"
fallback-class="screenshot-image"
fit="cover"
/>
<span>查看 {{ index + 1 }}</span>
</button>
</div>
<p v-else class="empty-line">暂无截图</p>
</section>
</div>
<el-dialog
:model-value="!!actionType"
:title="actionTitle"
width="560px"
@update:model-value="actionType = ''"
>
<div v-if="listing" class="dialog-body">
<p>
<strong>{{ listing.title }}</strong>
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写后台操作原因,会写入审计日志并通知号主"
/>
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
<el-dialog
v-model="transferVisible"
title="转移发布所有权"
width="680px"
class="transfer-owner-dialog"
>
<div v-if="listing" class="dialog-body transfer-dialog">
<div class="transfer-route">
<div class="transfer-owner-card">
<span>当前号主</span>
<strong>{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</strong>
<small>ID:{{ listing.owner_id }}</small>
</div>
<div class="transfer-arrow">→</div>
<div class="transfer-owner-card target" :class="{ empty: !selectedUser }">
<span>目标号主</span>
<strong v-if="selectedUser">{{ selectedUser.nickname || selectedUser.phone }}</strong>
<strong v-else>请选择用户</strong>
<small v-if="selectedUser"
>ID:{{ selectedUser.id }} · {{ selectedUser.phone || '-' }}</small
>
<small v-else>仅支持正常且已实名用户</small>
</div>
</div>
<div class="transfer-section">
<label>选择目标用户</label>
<el-input
v-model="userKeyword"
clearable
placeholder="输入手机号 / 昵称 / 用户ID 查询"
@keyup.enter="searchUsers"
@clear="searchUsers"
>
<template #append>
<el-button :icon="Search" :loading="userSearching" @click="searchUsers"
>查询</el-button
>
</template>
</el-input>
</div>
<div class="transfer-user-list">
<button
v-for="user in userOptions"
:key="user.id"
class="transfer-user"
:class="{ 'is-selected': selectedUser?.id === user.id }"
type="button"
@click="selectUser(user)"
>
<span class="transfer-user-main">
<strong>{{ user.nickname || user.phone || `用户${user.id}` }}</strong>
<small>{{ user.phone || '-' }} · ID {{ user.id }}</small>
</span>
<span class="transfer-user-meta">
<el-tag size="small" type="success">{{ userStatusLabel(user.status) }}</el-tag>
<el-tag size="small">{{ realnameStatusLabel(user.realname_status) }}</el-tag>
</span>
</button>
<el-empty
v-if="!userOptions.length"
description="输入关键词后查询已实名用户"
:image-size="72"
/>
</div>
<div class="transfer-section">
<label>转移原因</label>
<el-input
v-model="transferReason"
type="textarea"
:rows="3"
placeholder="填写转移原因,会写入审计日志"
/>
</div>
</div>
<template #footer>
<el-button @click="transferVisible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="!selectedUser || !selectedUserAvailable"
@click="submitTransferOwner"
>
确认转移
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped src="./AdminListingDetailView.css"></style>