1031 lines
35 KiB
Vue
1031 lines
35 KiB
Vue
<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>
|