Files
hfb_sys/frontend/src/features/listings/views/MobileListingDetailView.vue
T
yml2213 369b0647bb 合并 main 分支并融合订单列表最小化与 SQL 治理
- Auth/权限中间件取 main 演进版(错误细分、token 来源),保留 dev 的用户 cookie 与登出撤销语义
- 后台订单列表:融合 main 的首页免 COUNT 优化与关键词索引路由,保留 dev 的最小化 AdminOrderListItemDTO(结账明细仍由详情接口返回,同步调整对应集成测试)
- 保留 dev 的 token_version 冻结/解冻递增与 main 的状态变化判断及通知
- 前端:保留 dev 登出接口与 main 的 fetchMe silent 参数、SSE 登录跳转;后台订单表按 dev 最小化 DTO 移除号主到手列
2026-09-05 11:43:13 +08:00

574 lines
19 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 { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { fetchListing, type PublicListingDetail } 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 { formatCent, formatMoney } from '@/shared/utils/money'
import {
assetRegions,
formatAssetNumber,
formatEstimatedRentalDuration,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingChips,
getListingDisplayPrice,
getListingRentPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getLoginMethod,
getServerRegion,
readAssetNumber,
readAssetString,
} 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 listing = ref<PublicListingDetail | null>(null)
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 canCreateOrderAfterAgreement = computed(
() =>
virtualAgreementRead.value &&
renterAgreementRead.value &&
virtualAgreementChecked.value &&
renterAgreementChecked.value
)
onMounted(async () => {
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id))
recordListingView(listing.value)
} catch {
showToast({ message: '加载失败', icon: 'warning-o' })
} 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,
}
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
}
})
const detailMetrics = computed(() => {
if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value)
const secretKD = readAssetNumber(listing.value, 'secret_kd')
return [
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: '绝密KD',
value: secretKD > 0 ? formatAssetNumber(secretKD) : '--',
tone: 'coin',
},
{
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{
label: '预计可租',
value: formatEstimatedRentalDuration(listing.value),
tone: 'coin',
},
{
label: '价格',
value: `¥${formatMoney(getListingDisplayPrice(listing.value))}`,
tone: 'price',
},
{ label: '押金', value: `¥${formatCent(listing.value.deposit_amount_cent)}`, tone: '' },
]
})
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 detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
function readGroupedScreenshots(item: PublicListingDetail) {
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
const slots = [
{ key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', 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, string> = {
melee: '近战皮肤',
operator: '干员皮肤',
operatorGold: '干员金皮',
operatorRed: '干员红皮',
weapon: '武器皮肤',
other: '其他皮肤',
imported: '其他皮肤',
}
return Object.entries(groups as Record<string, unknown>)
.map(([key, value]) => ({
key,
title: titles[key] || key,
options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.filter(group => group.options.length)
})
/* 下单 */
async function handleCreateOrder() {
if (!listing.value) return
if (!session.token) {
showDialog({
title: '请先登录',
message: '下单需要登录账号,是否前往登录?',
confirmButtonText: '去登录',
cancelButtonText: '取消',
showCancelButton: true,
}).then(() => {
router.push({ path: '/m/login', query: { redirect: route.fullPath } })
})
return
}
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe()
} catch {
// 401 会由全局拦截器处理。
}
}
if (session.realnameStatus !== 'verified') {
showDialog({
title: '请先实名认证',
message: '租号下单前需要完成实名认证。',
confirmButtonText: '去认证',
cancelButtonText: '取消',
showCancelButton: true,
}).then(() => {
router.push({ path: '/m/realname', query: { redirect: route.fullPath } })
})
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) {
showToast({ message: readError(error, '协议加载失败'), icon: 'cross' })
} finally {
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
showToast('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return
ordering.value = true
try {
const order = await createOrder(listing.value.id)
showToast({ message: '订单已创建,正在打开支付', icon: 'passed' })
await router.push({ path: `/m/orders/${order.id}`, query: { pay: '1' } })
} catch (error) {
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
} finally {
ordering.value = false
}
}
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
}
}
async function copyListingCode() {
if (!listing.value) return
try {
await navigator.clipboard.writeText(formatListingCode(listing.value))
showToast({ message: '商品编号已复制', icon: 'passed' })
} catch {
showToast({ message: '复制失败', icon: 'cross' })
}
}
function handleToggleFavorite() {
if (!listing.value) return
const added = toggleFavorite(listing.value)
showToast({ message: added ? '已收藏账号' : '已取消收藏', icon: added ? 'star' : 'success' })
}
</script>
<template>
<main class="mobile-detail">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>账号详情</h1>
<button
v-if="listing"
class="favorite-header-btn"
type="button"
@click="handleToggleFavorite"
>
<van-icon :name="listingFavorited ? 'star' : 'star-o'" :size="20" />
</button>
</header>
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
加载中...
</van-loading>
<template v-else-if="listing">
<!-- 防骗提示 -->
<div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" />
<span>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span>
</div>
<!-- 封面图 -->
<div class="cover-area">
<AuthImage
v-if="detailScreenshots[0]?.url"
:source="detailScreenshots[0].url"
:alt="getListingTitle(listing)"
image-class="cover-img"
fit="cover"
loading="eager"
:preview-src-list="detailScreenshotUrls"
/>
<div v-else class="cover-placeholder">
<van-icon name="photo-o" :size="40" color="#ccc" />
<span>暂无截图</span>
</div>
<!-- 截图指示器 -->
<div v-if="detailScreenshots.length > 1" class="cover-count">
{{ detailScreenshots.length }}张
</div>
</div>
<!-- 标题信息 -->
<div class="info-card">
<div class="info-tag-row">
<van-tag plain type="primary" size="medium">{{ getServerRegion(listing) }}</van-tag>
<van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium">
{{ getLoginMethod(listing) }}
</van-tag>
<van-tag v-if="listing.rank_level" plain size="medium">{{ listing.rank_level }}</van-tag>
</div>
<button class="mobile-code-chip" type="button" @click="copyListingCode">
{{ formatListingCode(listing) }}
</button>
<button class="mobile-favorite-chip" type="button" @click="handleToggleFavorite">
<van-icon :name="listingFavorited ? 'star' : 'star-o'" :size="15" />
{{ listingFavorited ? '已收藏' : '收藏账号' }}
</button>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
</div>
<!-- 资产指标 -->
<div class="metric-row">
<div v-for="metric in detailMetrics" :key="metric.label" class="metric-item">
<span class="metric-label">{{ metric.label }}</span>
<strong class="metric-value" :class="metric.tone">{{ metric.value }}</strong>
</div>
</div>
<!-- 基础信息 -->
<div class="info-card">
<h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
<div class="info-line">
<span class="info-label">烽火等级</span>
<span class="info-text">
{{
readAssetNumber(listing, 'fire_level') > 0
? `${readAssetNumber(listing, 'fire_level')}级`
: '--'
}}
</span>
</div>
<div class="info-line">
<span class="info-label">M单价</span>
<span class="info-text">{{ formatRatio(listing) }}</span>
</div>
<div class="info-line">
<span class="info-label">绝密KD</span>
<span class="info-text">
{{
readAssetNumber(listing, 'secret_kd') > 0
? formatAssetNumber(readAssetNumber(listing, 'secret_kd'))
: '--'
}}
</span>
</div>
<div class="info-line">
<span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join('、') || '--' }}</span>
</div>
<div v-if="readAssetString(listing, 'ban_record')" class="info-line">
<span class="info-label">封禁记录</span>
<span class="info-text">{{ readAssetString(listing, 'ban_record') }}</span>
</div>
</div>
<div v-if="listing.description" class="info-card">
<h3 class="card-subtitle">号主备注</h3>
<p class="detail-desc">{{ listing.description }}</p>
</div>
<div v-if="getListingResources(listing).length" class="info-card">
<h3 class="card-subtitle">额外消耗品</h3>
<div class="resource-grid">
<div
v-for="resource in getListingResources(listing)"
:key="resource.key"
class="resource-pill"
>
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
</div>
</div>
</div>
<div v-if="detailSkinGroups.length" class="info-card">
<h3 class="card-subtitle">皮肤</h3>
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-detail-group">
<p>{{ group.title }}</p>
<div class="detail-chip-row">
<span v-for="skin in group.options" :key="skin">{{ skin }}</span>
</div>
</div>
</div>
<!-- 截图列表 -->
<div v-if="detailScreenshots.length" class="info-card">
<h3 class="card-subtitle">账号截图</h3>
<div class="screenshot-grid">
<figure
v-for="(shot, index) in detailScreenshots"
:key="shot.url"
class="screenshot-item"
>
<AuthImage
:source="shot.url"
:alt="shot.label"
image-class="screenshot-thumb"
fallback-class="screenshot-thumb"
fit="cover"
:preview-src-list="detailScreenshotUrls"
:preview-initial-index="index"
/>
<figcaption>{{ shot.label }}</figcaption>
</figure>
</div>
</div>
<!-- 留白给底部下单栏 -->
<div class="bottom-spacer"></div>
<!-- 底部下单栏(固定) -->
<div class="order-bar">
<div class="order-bar-left">
<div class="order-price">
<span class="price-label">价格</span>
<span class="price-amount">¥{{ orderTotal }}</span>
</div>
<div class="order-price-detail">
<span>租金 ¥{{ formatMoney(orderPriceBreakdown.rent) }}</span>
<span>额外 ¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span>
</div>
</div>
<van-button
type="primary"
round
class="order-btn"
:loading="ordering || agreementsLoading"
loading-text="下单中..."
@click="handleCreateOrder"
>
立即下单
</van-button>
</div>
<van-popup
v-model:show="agreementVisible"
round
closeable
position="bottom"
lock-scroll
class="agreement-popup"
>
<div v-if="agreements" class="agreement-popup-body">
<h3>下单协议确认</h3>
<p>请完整阅读并勾选以下两份协议后继续下单。</p>
<section class="agreement-panel">
<strong>{{ agreements.virtual_asset_purchase.title }}</strong>
<div
ref="virtualAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('virtual')"
>
{{ agreements.virtual_asset_purchase.content }}
</div>
<van-checkbox
v-model="virtualAgreementChecked"
:disabled="!virtualAgreementRead"
icon-size="18px"
>
我已阅读并同意《{{ agreements.virtual_asset_purchase.title }}》
</van-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
<section class="agreement-panel">
<strong>{{ agreements.renter_agreement.title }}</strong>
<div
ref="renterAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('renter')"
>
{{ agreements.renter_agreement.content }}
</div>
<van-checkbox
v-model="renterAgreementChecked"
:disabled="!renterAgreementRead"
icon-size="18px"
>
我已阅读并同意《{{ agreements.renter_agreement.title }}》
</van-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
<van-button
block
round
type="primary"
class="agreement-confirm-btn"
:loading="ordering"
:disabled="!canCreateOrderAfterAgreement"
@click="handleConfirmAgreementAndCreateOrder"
>
同意协议并下单
</van-button>
</div>
</van-popup>
</template>
<!-- 空状态 -->
<div v-else class="empty-state">
<van-icon name="info-o" :size="48" color="#ccc" />
<p>未找到该账号信息</p>
</div>
</main>
</template>
<style scoped src="./MobileListingDetailView.css"></style>