Files
hfb_sys/frontend/src/features/listings/views/ListingDetailView.vue
T

904 lines
32 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 { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ShoppingCart, Star, StarFilled, ArrowLeft, DocumentCopy } from '@element-plus/icons-vue'
import { goBackOrHome } from '@/shared/utils/routerBack'
import { fetchListing, type Listing } from '@/features/listings/api/listings'
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
import {
createOrder,
fetchOrderAgreements,
type OrderAgreements,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { roundMoney, formatMoney, formatCent } from '@/shared/utils/money'
import {
assetRegions,
formatAssetNumber,
formatEstimatedRentalDuration,
formatGameName,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingChips,
getListingDisplayPrice,
getListingRentPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getOnlineTimeText,
getLoginMethod,
getServerRegion,
readAssetNumber,
readAssetString,
getRatioValue,
isTopTierInsurance,
getScreenshotThumbURL,
getScreenshotMediumURL,
getScreenshotRawURL,
type ListingDisplayResource,
} from '@/shared/utils/listingDisplay'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const ordering = ref(false)
const agreementsLoading = ref(false)
const agreementVisible = ref(false)
const agreements = ref<OrderAgreements | null>(null)
const virtualAgreementRead = ref(false)
const renterAgreementRead = ref(false)
const virtualAgreementChecked = ref(false)
const renterAgreementChecked = ref(false)
const virtualAgreementRef = ref<HTMLElement | null>(null)
const renterAgreementRef = ref<HTMLElement | null>(null)
const { recordListingView, isFavorite, toggleFavorite } = useListingCollections()
const activeImageIndex = ref(0)
const canCreateOrderAfterAgreement = computed(
() =>
virtualAgreementRead.value &&
renterAgreementRead.value &&
virtualAgreementChecked.value &&
renterAgreementChecked.value
)
const listing = ref<Listing | null>(null)
onMounted(async () => {
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id))
recordListingView(listing.value)
} finally {
loading.value = false
}
})
const listingFavorited = computed(() => (listing.value ? isFavorite(listing.value.id) : false))
const orderTotal = computed(() => {
if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value))
})
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
return {
rent: 0,
consumable: 0,
total: 0,
}
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
total: roundMoney(getListingDisplayPrice(listing.value)),
}
})
// 核心资产微卡计算(同首页设计标准)
const hafCoinDisplay = computed(() => {
if (!listing.value) return '--'
return formatHafCoinM(getCoinWan(listing.value))
})
const insuranceSlot = computed(() => {
if (!listing.value) return '--'
return readAssetString(listing.value, 'season_insurance') || '--'
})
const isTopTierInsuranceSlot = computed(() => isTopTierInsurance(insuranceSlot.value))
const ratioValue = computed(() => {
if (!listing.value) return 0
return getRatioValue(listing.value)
})
const ratioDisplay = computed(() => {
if (ratioValue.value <= 0) return null
return `1元 = ${ratioValue.value.toFixed(1)}w`
})
const rentalDuration = computed(() => {
if (!listing.value) return '--'
return formatEstimatedRentalDuration(listing.value)
})
const dailyLoss = computed(() => {
if (!listing.value) return ''
return getDailyLoss(listing.value)
})
// 平台/区服特性徽标
const accessTags = computed(() => {
if (!listing.value) return []
return [getServerRegion(listing.value), getLoginMethod(listing.value)]
.filter(Boolean)
.map(item => accessBadgeMeta(item))
})
function accessBadgeMeta(value: string) {
const text = value.trim()
if (text.includes('微信')) return { label: text, icon: 'wechat', tone: 'wechat' }
if (text.toUpperCase().includes('QQ')) return { label: text, icon: 'qq', tone: 'qq' }
if (text.includes('扫码')) return { label: text, icon: 'scan', tone: 'scan' }
if (text.includes('账') || text.includes('密码'))
return { label: text, icon: 'lock', tone: 'password' }
if (text.includes('Steam')) return { label: text, icon: 'desktop-o', tone: 'steam' }
return { label: text, icon: 'bookmark-o', tone: 'default' }
}
// 截图多图展台
const detailScreenshots = computed(() => {
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
url,
}))
})
const detailScreenshotRawUrls = computed(() =>
detailScreenshots.value.map(shot => getScreenshotRawURL(shot.url))
)
const activeScreenshot = computed(() => {
if (detailScreenshots.value.length === 0) return null
return detailScreenshots.value[activeImageIndex.value] || detailScreenshots.value[0]
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
const slots = [
{ key: 'coin', label: '纯币截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
]
return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}))
})
}
// 皮肤清单(带高光品级)
const detailSkinGroups = computed(() => {
if (!listing.value) return []
const groups = listing.value.asset_summary?.skin_groups
if (typeof groups !== 'object' || groups === null) return []
const titles: Record<string, { title: string; type: string }> = {
melee: { title: '近战皮肤', type: 'melee' },
operatorRed: { title: '干员红皮', type: 'operatorRed' },
operatorGold: { title: '干员金皮', type: 'operatorGold' },
operator: { title: '干员皮肤', type: 'operator' },
weapon: { title: '武器皮肤', type: 'weapon' },
other: { title: '其他皮肤', type: 'other' },
imported: { title: '其他皮肤', type: 'other' },
}
return Object.entries(groups as Record<string, unknown>)
.map(([key, value]) => {
const meta = titles[key] || { title: key, type: 'other' }
return {
key,
type: meta.type,
title: meta.title,
options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}
})
.filter(group => group.options.length)
})
const totalSkinCount = computed(() => {
return detailSkinGroups.value.reduce((acc, g) => acc + g.options.length, 0)
})
// 规格胶囊(过滤已展示的哈夫币、保险、AWM)
const secondaryChips = computed(() => {
if (!listing.value) return []
return getListingChips(listing.value).filter(
chip => chip.label !== '哈夫币' && chip.label !== '保险格数' && chip.label !== 'AWM'
)
})
// 账号基础档案 12 宫格
const accountRows = computed(() => {
if (!listing.value) return []
const regions = assetRegions(listing.value)
const secretKD = readAssetNumber(listing.value, 'secret_kd')
const fireLevel = readAssetNumber(listing.value, 'fire_level')
return [
{ label: '所属区服', value: getServerRegion(listing.value) || '--' },
{ label: '上号方式', value: getLoginMethod(listing.value) || '--' },
{ label: '游戏段位', value: listing.value.rank_level || '--' },
{ label: '烽火等级', value: fireLevel > 0 ? `${fireLevel}级` : '--' },
{ label: '体力等级', value: readAssetString(listing.value, 'stamina_level') || '--' },
{ label: '负重等级', value: readAssetString(listing.value, 'load_level') || '--' },
{ label: '绝密KD', value: secretKD > 0 ? formatAssetNumber(secretKD) : '--' },
{ label: 'M单价', value: formatRatio(listing.value) },
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
{ label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无封禁记录' },
]
})
// 仓库物资
const resourceItems = computed(() => {
if (!listing.value) return []
return getListingResources(listing.value)
})
function formatResourceQuantity(resource: ListingDisplayResource) {
const suffix = resource.key === 'awmAmmo' ? '发' : ''
const val = Number.isInteger(resource.quantity)
? String(resource.quantity)
: resource.quantity.toFixed(1)
return `${val}${suffix}`
}
async function handleCreateOrder() {
if (!listing.value) return
if (!session.token) {
await router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe()
} catch {
// 登录态失效时由全局请求拦截处理。
}
}
if (session.realnameStatus !== 'verified') {
try {
await ElMessageBox.confirm('租号下单前需要完成实名认证。', '请先实名认证', {
confirmButtonText: '去认证',
cancelButtonText: '取消',
type: 'warning',
})
await router.push({ path: '/realname', query: { redirect: route.fullPath } })
} catch {
// 用户取消认证时停留在当前页面。
}
return
}
await openAgreementBeforeOrder()
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true
try {
agreements.value = await fetchOrderAgreements()
virtualAgreementRead.value = false
renterAgreementRead.value = false
virtualAgreementChecked.value = false
renterAgreementChecked.value = false
agreementVisible.value = true
await nextTick()
updateAgreementReadState('virtual')
updateAgreementReadState('renter')
} catch (error) {
ElMessage.error(readError(error, '协议加载失败'))
} finally {
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
ElMessage.warning('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return
ordering.value = true
try {
const order = await createOrder(listing.value.id)
ElMessage.success('订单已创建,正在打开支付')
await router.push({ path: `/orders/${order.id}`, query: { pay: '1' } })
} catch (error) {
ElMessage.error(readError(error, '下单失败'))
} finally {
ordering.value = false
}
}
async function copyListingCode() {
if (!listing.value) return
try {
await navigator.clipboard.writeText(formatListingCode(listing.value))
ElMessage.success('商品编号已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
}
function handleAgreementScroll(type: 'virtual' | 'renter') {
updateAgreementReadState(type)
}
function updateAgreementReadState(type: 'virtual' | 'renter') {
const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
if (!el) return
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
if (type === 'virtual') {
virtualAgreementRead.value = read
} else {
renterAgreementRead.value = read
}
}
// 锁定/解锁背景滚动
watch(agreementVisible, visible => {
const pcMain = document.querySelector('.pc-main') as HTMLElement
if (!pcMain) return
if (visible) {
const scrollTop = pcMain.scrollTop || 0
pcMain.style.overflow = 'hidden'
pcMain.style.position = 'fixed'
pcMain.style.top = `-${scrollTop}px`
pcMain.style.left = '0'
pcMain.style.right = '0'
pcMain.style.width = '100%'
pcMain.dataset.scrollTop = String(scrollTop)
} else {
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10)
pcMain.style.overflow = ''
pcMain.style.position = ''
pcMain.style.top = ''
pcMain.style.left = ''
pcMain.style.right = ''
pcMain.style.width = ''
pcMain.scrollTop = scrollTop
delete pcMain.dataset.scrollTop
}
})
function handleToggleFavorite() {
if (!listing.value) return
const added = toggleFavorite(listing.value)
ElMessage.success(added ? '已收藏账号' : '已取消收藏')
}
function goBack() {
goBackOrHome(router)
}
</script>
<template>
<section class="pc-detail-page" v-loading="loading">
<!-- 顶部导航与安全保障条 -->
<div class="top-nav-bar">
<button class="back-pill-btn" type="button" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
<span>返回租号大厅</span>
</button>
<div class="safe-notice-strip">
<van-icon name="shield-o" class="safe-icon" :size="16" />
<span class="safe-text"
>平台官方担保:下单后请严格按照平台交付流程确认收号与归还,切勿脱离平台私下交易。</span
>
</div>
</div>
<!-- 详情主布局:左侧详尽档案 + 右侧粘性下单面板 -->
<div v-if="listing" class="pc-detail-layout">
<!-- 左侧内容流 -->
<div class="pc-detail-main">
<!-- 账号抬头与核心属性 -->
<section class="detail-card header-card">
<div class="header-meta-row">
<div class="meta-tags-wrap">
<button
class="code-badge-btn"
type="button"
@click="copyListingCode"
title="点击复制商品编号"
>
<span>{{ formatListingCode(listing) }}</span>
<el-icon :size="12"><DocumentCopy /></el-icon>
</button>
<span
v-for="tag in accessTags"
:key="tag.label"
class="platform-pill"
:class="tag.tone"
>
<van-icon :name="tag.icon" :size="13" />
<span>{{ tag.label }}</span>
</span>
<span v-if="listing.rank_level" class="platform-pill rank">
<van-icon name="diamond-o" :size="13" />
<span>{{ listing.rank_level }}</span>
</span>
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
<i class="status-dot" />
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
</span>
</div>
<button
class="favorite-action-btn"
:class="{ 'is-active': listingFavorited }"
type="button"
@click="handleToggleFavorite"
>
<el-icon :size="15">
<component :is="listingFavorited ? StarFilled : Star" />
</el-icon>
<span>{{ listingFavorited ? '已收藏' : '收藏账号' }}</span>
</button>
</div>
<!-- 标题与副标题 -->
<div class="header-title-block">
<h1 class="listing-main-title">{{ getListingTitle(listing) }}</h1>
<p v-if="getListingSubtitle(listing)" class="listing-subtitle">
{{ getListingSubtitle(listing) }}
</p>
</div>
<!-- 核心资产四大微卡(完全对齐首页) -->
<div class="core-assets-bar">
<!-- 哈夫币 -->
<div class="asset-tile coin-tile">
<span class="tile-label">哈夫币资产</span>
<strong class="tile-value text-orange">{{ hafCoinDisplay }}</strong>
</div>
<!-- 保险格数 -->
<div class="asset-tile slot-tile" :class="{ 'top-insurance': isTopTierInsuranceSlot }">
<span class="tile-label">安全箱格数</span>
<strong
class="tile-value"
:class="isTopTierInsuranceSlot ? 'text-orange' : 'text-slate'"
>{{ insuranceSlot }}</strong
>
</div>
<!-- 折算币比 -->
<div v-if="ratioDisplay" class="asset-tile ratio-tile">
<span class="tile-label">折算币比</span>
<strong class="tile-value text-amber">{{ ratioDisplay }}</strong>
</div>
<!-- 租期与日耗 -->
<div class="asset-tile rental-tile">
<span class="tile-label">租用与损耗</span>
<div class="tile-subvalues">
<span class="val-pill green">可租 {{ rentalDuration }}</span>
<span v-if="dailyLoss" class="val-pill warm">消耗 {{ dailyLoss }}/天</span>
</div>
</div>
</div>
</section>
<!-- 多图互动验号展台 (Gallery Showcase) -->
<section class="detail-card gallery-card">
<div class="section-title-bar">
<div class="title-left">
<h2>验号图片展台</h2>
<span class="title-tip">真实游戏截图,点击可全屏高清放大查看细节</span>
</div>
<span class="gallery-count">共 {{ detailScreenshots.length }} 张截图</span>
</div>
<!-- 主展台主图 -->
<div class="gallery-stage">
<div v-if="activeScreenshot" class="stage-viewer">
<AuthImage
:source="getScreenshotMediumURL(activeScreenshot.url)"
:alt="activeScreenshot.label"
image-class="stage-image"
fallback-class="stage-image"
fit="contain"
:preview-src-list="detailScreenshotRawUrls"
:preview-initial-index="activeImageIndex"
/>
<!-- 左上角分类角标 -->
<span class="stage-tag">{{ activeScreenshot.label }}</span>
<!-- 右下角放大提示 -->
<div class="stage-zoom-hint">
<van-icon name="search" :size="13" />
<span>点击放大查看</span>
</div>
</div>
<div v-else class="stage-empty">
<span>暂无验号截图</span>
</div>
</div>
<!-- 底部缩略图胶卷导轨 -->
<div v-if="detailScreenshots.length > 1" class="thumbnail-filmstrip">
<button
v-for="(shot, idx) in detailScreenshots"
:key="shot.url + idx"
type="button"
class="thumbnail-item"
:class="{ 'is-active': idx === activeImageIndex }"
@click="activeImageIndex = idx"
@mouseenter="activeImageIndex = idx"
>
<AuthImage
:source="getScreenshotThumbURL(shot.url)"
:alt="shot.label"
image-class="thumb-img"
fallback-class="thumb-img"
fit="cover"
/>
<span class="thumb-label">{{ shot.label }}</span>
</button>
</div>
</section>
<!-- 账号核心档案参数矩阵 -->
<section class="detail-card specs-card">
<div class="section-title-bar">
<div class="title-left">
<h2>账号核心档案</h2>
<span class="title-tip"
>{{ formatGameName(listing.game_name, '三角洲行动') }} 资产详细规格</span
>
</div>
</div>
<!-- 次要规格标签流 -->
<div v-if="secondaryChips.length > 0" class="secondary-chips-flow">
<span v-for="chip in secondaryChips" :key="chip.label" class="secondary-spec-pill">
<span class="pill-k">{{ chip.label }}</span>
<strong class="pill-v">{{ chip.value }}</strong>
</span>
</div>
<!-- 12 宫格档案矩阵 -->
<div class="specs-grid">
<div v-for="row in accountRows" :key="row.label" class="spec-cell">
<span class="cell-label">{{ row.label }}</span>
<strong class="cell-val">{{ row.value }}</strong>
</div>
</div>
</section>
<!-- 精选皮肤展台(若有) -->
<section v-if="detailSkinGroups.length" class="detail-card skin-card">
<div class="section-title-bar">
<div class="title-left">
<h2>精选皮肤展台</h2>
<span class="title-tip">包含近战、金皮、红皮及武器皮肤</span>
</div>
<span class="gallery-count">共 {{ totalSkinCount }} 款精选</span>
</div>
<div class="skin-groups-container">
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-group-block">
<div class="skin-group-header">
<span class="skin-group-title">{{ group.title }}</span>
<span class="skin-group-count">{{ group.options.length }} 款</span>
</div>
<div class="skin-pill-wrap">
<span
v-for="opt in group.options"
:key="opt"
class="skin-pill"
:class="`skin-type-${group.type}`"
>
<i class="quality-dot" />
<span>{{ opt }}</span>
</span>
</div>
</div>
</div>
</section>
<!-- 仓库贵重物资 & 额外消耗品(若有) -->
<section v-if="resourceItems.length" class="detail-card resources-card">
<div class="section-title-bar">
<div class="title-left">
<h2>仓库物资与消耗品</h2>
<span class="title-tip">随号附带的仓库高价值装备物资</span>
</div>
<span class="gallery-count">{{ resourceItems.length }} 种物资</span>
</div>
<div class="resources-grid">
<div v-for="res in resourceItems" :key="res.key" class="resource-tile">
<div class="res-top">
<span class="res-name">{{ res.label }}</span>
<strong class="res-qty">{{ formatResourceQuantity(res) }}</strong>
</div>
<div class="res-bottom">
<span v-if="res.mode === '赠送'" class="res-badge gift">随号赠送</span>
<span v-else-if="res.mode === '收费'" class="res-badge charge">
额外收费 <small v-if="res.amount > 0">¥{{ formatMoney(res.amount) }}</small>
</span>
<span v-else class="res-badge standard">{{ res.mode || '账号包内' }}</span>
</div>
</div>
</div>
</section>
<!-- 号主说明与交接备注 -->
<section class="detail-card note-card">
<div class="section-title-bar">
<div class="title-left">
<h2>号主留言与说明</h2>
</div>
</div>
<div class="seller-note-box">
<van-icon name="chat-o" :size="18" class="note-quote-icon" />
<p class="note-content">
{{ listing.description || '号主暂未填写特殊交接要求,可正常直接下单租用。' }}
</p>
</div>
</section>
<!-- 平台四大安全保障 -->
<section class="detail-card trust-card">
<div class="section-title-bar">
<div class="title-left">
<h2>平台官方交易保障</h2>
<span class="title-tip">哈夫币租号平台全面护航您的每次交易</span>
</div>
</div>
<div class="trust-features-grid">
<div class="trust-item">
<div class="trust-icon-box orange">
<van-icon name="shield-o" :size="20" />
</div>
<div class="trust-text">
<strong>官方资金托管</strong>
<p>租金与押金全程平台监管,确认收号无误后才结算</p>
</div>
</div>
<div class="trust-item">
<div class="trust-icon-box blue">
<van-icon name="passed" :size="20" />
</div>
<div class="trust-text">
<strong>极速自动退押</strong>
<p>租期结束按规范归还并确认无损后,押金原路秒退</p>
</div>
</div>
<div class="trust-item">
<div class="trust-icon-box green">
<van-icon name="service-o" :size="20" />
</div>
<div class="trust-text">
<strong>专人客服协助</strong>
<p>上号交接若遇异常,平台官方客服随时介入协助</p>
</div>
</div>
<div class="trust-item">
<div class="trust-icon-box amber">
<van-icon name="certificate" :size="20" />
</div>
<div class="trust-text">
<strong>双向实名认证</strong>
<p>买卖双方均通过公安实名比对校验,杜绝欺诈</p>
</div>
</div>
</div>
</section>
</div>
<!-- 右侧:常驻粘性结算下单面板 -->
<aside class="pc-order-panel">
<div class="order-sticky-box">
<!-- 卡片头部 -->
<div class="panel-header">
<div class="panel-title-wrap">
<span class="panel-eyebrow">平台托管交接</span>
<h2 class="panel-title">立即租用</h2>
</div>
<span class="order-status-pill" :class="{ 'is-busy': listing.in_transaction }">
<i class="dot" />
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
</span>
</div>
<!-- 价格与费用结构总览(精美暖色渐变卡) -->
<div class="checkout-price-card">
<div class="price-header-row">
<span class="price-caption">租赁合计</span>
<div class="price-total-display">
<small>¥</small>
<strong>{{ orderTotal }}</strong>
</div>
</div>
<!-- 费用拆解 -->
<div class="price-breakdown-list">
<div class="breakdown-row">
<span class="b-label">账号基础租金</span>
<span class="b-val">¥{{ formatMoney(orderPriceBreakdown.rent) }}</span>
</div>
<div v-if="orderPriceBreakdown.consumable > 0" class="breakdown-row">
<span class="b-label">随号额外物资</span>
<span class="b-val">¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span>
</div>
</div>
<!-- 押金行 -->
<div class="deposit-guarantee-row">
<div class="dep-left">
<span class="dep-title">押金另付</span>
<small class="dep-sub">归还验收无损自动原路退还</small>
</div>
<strong class="dep-val">
{{
listing.deposit_amount_cent > 0
? `¥${formatCent(listing.deposit_amount_cent)}`
: '免押金'
}}
</strong>
</div>
</div>
<!-- 规格速览确认清单 -->
<div class="order-quick-summary">
<div class="summary-item">
<span class="s-label">区服方式</span>
<strong class="s-val"
>{{ getServerRegion(listing) }} · {{ getLoginMethod(listing) }}</strong
>
</div>
<div class="summary-item">
<span class="s-label">预计可租</span>
<strong class="s-val text-green">{{ formatEstimatedRentalDuration(listing) }}</strong>
</div>
<div class="summary-item">
<span class="s-label">方便上号</span>
<strong class="s-val">{{ getOnlineTimeText(listing) || '--' }}</strong>
</div>
</div>
<!-- 下单操作区 -->
<div class="order-cta-block">
<button
type="button"
class="primary-order-btn"
:disabled="listing.in_transaction || ordering || agreementsLoading"
@click="handleCreateOrder"
>
<el-icon :size="18"><ShoppingCart /></el-icon>
<span>{{ listing.in_transaction ? '该账号交易中' : '立即下单租用' }}</span>
</button>
<button
type="button"
class="secondary-favorite-btn"
:class="{ 'is-active': listingFavorited }"
@click="handleToggleFavorite"
>
<el-icon :size="16">
<component :is="listingFavorited ? StarFilled : Star" />
</el-icon>
<span>{{ listingFavorited ? '取消收藏' : '收藏账号' }}</span>
</button>
</div>
<!-- 底部安全信任脚标 -->
<div class="order-panel-trust-footer">
<div class="trust-badge-row">
<span><van-icon name="passed" /> 平台托管</span>
<span><van-icon name="passed" /> 极速验号</span>
<span><van-icon name="passed" /> 秒退押金</span>
</div>
</div>
</div>
</aside>
</div>
<!-- 下单协议确认弹窗 (保持完整业务逻辑) -->
<el-dialog
v-model="agreementVisible"
title="下单协议确认"
width="860px"
class="order-agreement-dialog"
:close-on-click-modal="false"
>
<div v-if="agreements" class="agreement-dialog-body">
<p class="agreement-tip">请完整阅读并勾选以下两份协议后继续下单:</p>
<div class="agreement-grid">
<section class="agreement-panel">
<h3>{{ agreements.virtual_asset_purchase.title }}</h3>
<div
ref="virtualAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('virtual')"
>
{{ agreements.virtual_asset_purchase.content }}
</div>
<el-checkbox v-model="virtualAgreementChecked" :disabled="!virtualAgreementRead">
我已阅读并同意《{{ agreements.virtual_asset_purchase.title }}》
</el-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
<section class="agreement-panel">
<h3>{{ agreements.renter_agreement.title }}</h3>
<div
ref="renterAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('renter')"
>
{{ agreements.renter_agreement.content }}
</div>
<el-checkbox v-model="renterAgreementChecked" :disabled="!renterAgreementRead">
我已阅读并同意《{{ agreements.renter_agreement.title }}》
</el-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
</div>
</div>
<template #footer>
<el-button @click="agreementVisible = false">取消</el-button>
<el-button
type="primary"
:loading="ordering"
:disabled="!canCreateOrderAfterAgreement"
@click="handleConfirmAgreementAndCreateOrder"
>
同意协议并下单
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped src="./ListingDetailView.css"></style>