merge(main): 同步主站最新 UI 与审核优化代码并保持分销多租户隔离

This commit is contained in:
yml2213
2026-09-07 17:37:31 +08:00
51 changed files with 11152 additions and 4950 deletions
@@ -332,3 +332,19 @@ export async function rejectListing(id: number, reason: string) {
})
return data.data
}
export interface AdminListingScreenshotsPayload {
screenshot_urls: string[]
screenshot_groups?: Record<string, unknown>
}
export async function adminUpdateListingScreenshots(
id: number,
payload: AdminListingScreenshotsPayload
) {
const { data } = await apiClient.put<ApiResponse<Listing>>(
`/admin/listings/${id}/screenshots`,
payload
)
return data.data
}
@@ -10,8 +10,15 @@ interface HomeBannerSlide {
image_url?: string
}
interface StatItem {
label: string
value: string
hint?: string
}
interface Props {
banners: HomeBannerSlide[]
stats?: StatItem[]
}
const props = defineProps<Props>()
@@ -45,7 +52,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
<template>
<section class="hero-board">
<el-carousel height="240px" indicator-position="outside" :interval="3600">
<el-carousel height="200px" :interval="3600">
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
<div
class="hero-slide"
@@ -74,6 +81,14 @@ function hasSlideCopy(slide: HomeBannerSlide) {
</el-carousel-item>
</el-carousel>
<!-- 右下角半透明悬浮统计标签 -->
<div v-if="stats && stats.length" class="banner-stat-chips">
<div v-for="st in stats" :key="st.label" class="stat-pill" :title="st.hint">
<strong class="stat-pill-val">{{ st.value }}</strong>
<span class="stat-pill-lbl">{{ st.label }}</span>
</div>
</div>
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrls"
@@ -87,8 +102,10 @@ function hasSlideCopy(slide: HomeBannerSlide) {
<style scoped>
.hero-board {
position: relative;
min-width: 0;
border-radius: 16px;
width: 100%;
border-radius: 14px;
overflow: hidden;
background: #ffffff;
}
@@ -98,7 +115,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
}
.hero-board :deep(.el-carousel__container) {
height: 240px;
height: 200px;
overflow: hidden;
}
@@ -111,7 +128,7 @@ function hasSlideCopy(slide: HomeBannerSlide) {
height: 100%;
display: flex;
align-items: center;
padding: 40px;
padding: 24px 36px;
overflow: hidden;
color: #17233d;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 58%, #fff7ed 100%);
@@ -160,8 +177,8 @@ function hasSlideCopy(slide: HomeBannerSlide) {
.hero-copy span {
color: #ff6a00;
font-size: 13px;
font-weight: 900;
font-size: 12px;
font-weight: 700;
}
.hero-slide.has-image .hero-copy {
@@ -173,35 +190,76 @@ function hasSlideCopy(slide: HomeBannerSlide) {
}
.hero-copy h1 {
margin: 12px 0;
font-size: 32px;
font-weight: 900;
margin: 6px 0;
font-size: 22px;
font-weight: 800;
line-height: 1.2;
}
.hero-copy p {
display: inline-block;
margin: 0;
padding: 6px 12px;
padding: 3px 10px;
border-radius: 999px;
color: #1e293b;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(4px);
font-size: 14px;
font-weight: 800;
font-size: 12px;
font-weight: 700;
}
.hero-slide em {
position: absolute;
top: 22px;
right: 24px;
top: 14px;
right: 18px;
z-index: 1;
padding: 6px 10px;
padding: 4px 9px;
border-radius: 999px;
color: #ffffff;
background: #ff6a00;
font-size: 12px;
font-size: 11px;
font-style: normal;
font-weight: 900;
font-weight: 700;
}
/* 悬浮微型统计标签栏 */
.banner-stat-chips {
position: absolute;
bottom: 12px;
right: 16px;
display: flex;
align-items: center;
gap: 8px;
z-index: 3;
}
.stat-pill {
display: inline-flex;
align-items: baseline;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #ffffff;
font-size: 12px;
}
.stat-pill-val {
font-size: 14px;
font-weight: 700;
color: #fb923c;
}
.stat-pill-lbl {
font-size: 11px;
color: #cbd5e1;
}
@media (max-width: 768px) {
.banner-stat-chips {
display: none;
}
}
</style>
@@ -35,134 +35,79 @@ function selectZone(key: string) {
</script>
<template>
<div class="zone-tabs">
<div class="zone-pill-group">
<button
v-for="zone in zones"
:key="zone.key"
type="button"
class="zone-tab"
class="zone-pill-item"
:class="{ active: activeZone === zone.key }"
:title="zone.hint"
@click="selectZone(zone.key)"
>
<span class="zone-icon" aria-hidden="true">
<el-icon><component :is="zoneIcons[zone.key] || Grid" /></el-icon>
</span>
<span class="zone-title">
<strong>{{ zone.label }}</strong>
<span class="zone-count" :class="{ 'is-empty': zone.count === 0 }">{{ zone.count }}</span>
</span>
<small>{{ zone.hint }}</small>
<el-icon class="zone-pill-icon"><component :is="zoneIcons[zone.key] || Grid" /></el-icon>
<span class="zone-pill-label">{{ zone.label }}</span>
<span class="zone-pill-badge" :class="{ 'is-zero': zone.count === 0 }">{{ zone.count }}</span>
</button>
</div>
</template>
<style scoped>
.zone-tabs {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.zone-tab {
.zone-pill-group {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 14px;
border: 1px solid #eef1f5;
border-radius: 14px;
background: #ffffff;
cursor: pointer;
text-align: left;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.zone-tab:hover {
border-color: #ffd4b0;
background: #fffaf5;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.08);
}
.zone-tab.active {
border-color: #ff6a00;
background: #fff7ed;
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.1);
}
.zone-icon {
.zone-pill-item {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9px;
background: #f3f5f9;
color: #94a3b8;
font-size: 18px;
transition:
background-color 0.18s ease,
color 0.18s ease;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
color: #475569;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.18s ease;
}
.zone-tab:hover .zone-icon,
.zone-tab.active .zone-icon {
background: #ffe6d2;
.zone-pill-item:hover {
border-color: #ffd4b0;
color: #ff6a00;
background: #fffaf5;
}
.zone-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
.zone-pill-item.active {
border-color: #ff6a00;
background: #ff6a00;
color: #ffffff;
box-shadow: 0 2px 8px rgba(255, 106, 0, 0.25);
}
.zone-title strong {
overflow: hidden;
font-size: 15px;
font-weight: 700;
color: #17233d;
text-overflow: ellipsis;
white-space: nowrap;
.zone-pill-icon {
font-size: 14px;
}
.zone-count {
flex-shrink: 0;
font-size: 15px;
font-weight: 800;
color: #ff6a00;
font-variant-numeric: tabular-nums;
}
.zone-count.is-empty {
color: #c0c8d4;
.zone-pill-badge {
padding: 1px 6px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.zone-tab small {
overflow: hidden;
font-size: 12px;
color: #7b8798;
text-overflow: ellipsis;
white-space: nowrap;
.zone-pill-item.active .zone-pill-badge {
background: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
@media (max-width: 900px) {
.zone-tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.zone-tabs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.zone-pill-badge.is-zero {
opacity: 0.6;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -122,14 +122,43 @@ export function useListingQuery(
}
)
function restoreSnapshot(snapshot: {
listings: Listing[]
totalListings: number
zoneCounts: Record<string, number>
currentPage: number
hasMoreListings: boolean
}) {
listings.value = snapshot.listings.slice()
totalListings.value = snapshot.totalListings
zoneCounts.value = { ...snapshot.zoneCounts }
currentPage.value = snapshot.currentPage
hasMoreListings.value = snapshot.hasMoreListings
loading.value = false
loadingMore.value = false
}
function getSnapshot() {
return {
listings: listings.value.slice(),
totalListings: totalListings.value,
zoneCounts: { ...zoneCounts.value },
currentPage: currentPage.value,
hasMoreListings: hasMoreListings.value,
}
}
return {
loading,
loadingMore,
listings,
totalListings,
zoneCounts,
currentPage,
hasMoreListings,
loadListingsPage,
zoneCount,
restoreSnapshot,
getSnapshot,
}
}
+198 -126
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { usePcHomeCacheStore } from '@/stores/pcHomeCache'
import {
defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
@@ -36,7 +37,6 @@ import {
import { useListingQuery } from '@/features/listings/composables/useListingQuery'
import HomeAnnouncement from '../components/HomeAnnouncement.vue'
import HomeBanner from '../components/HomeBanner.vue'
import HomeStats from '../components/HomeStats.vue'
import HomeFilters from '../components/HomeFilters.vue'
import HomeZones from '../components/HomeZones.vue'
import ListingCard from '../components/ListingCard.vue'
@@ -96,6 +96,8 @@ const {
hasMoreListings,
loadListingsPage,
zoneCount,
restoreSnapshot,
getSnapshot,
} = useListingQuery(filters, sortBy, activeZone)
watch(
@@ -182,6 +184,7 @@ async function loadHome() {
publishOptions.value = emptyListingPublishOptions
} finally {
loading.value = false
saveToCache()
restorePcHomeScroll()
}
}
@@ -303,65 +306,118 @@ function buildDesktopHomeQueryValues(): Record<string, HomeQueryValue> {
}
}
// ========== PC 首页滚动位置持久化 ==========
// 详情页返回/浏览器后退时,首页列表异步加载导致页面高度不足,
// vue-router 的 savedPosition 恢复会被浏览器钳制到错误位置,
// 因此按「筛选签名」显式保存/恢复滚动位置,确保回到上次筛选的位置。
// ========== PC 首页状态与滚动持久化 ==========
// 详情页返回/浏览器后退时,利用 Pinia 缓存同步恢复全部已加载列表与高度,
// 使首帧渲染即可完成完整布局,结合 instant 滚动定位彻底解决「从顶部闪跳」问题。
const pcHomeScrollStorageKey = 'hfb.home.scroll.desktop'
const pcHomeCache = usePcHomeCacheStore()
function restoreFromCache(): boolean {
const signature = desktopHomeStateSignature()
if (!pcHomeCache.matchList(signature)) return false
restoreSnapshot({
listings: pcHomeCache.listings,
totalListings: pcHomeCache.totalListings,
zoneCounts: pcHomeCache.zoneCounts,
currentPage: pcHomeCache.currentPage,
hasMoreListings: pcHomeCache.hasMoreListings,
})
if (pcHomeCache.homeConfigLoaded) {
announcements.value = pcHomeCache.announcements.slice()
banners.value = pcHomeCache.banners.map(slide => ({ ...slide }))
awRecycle.value = { ...pcHomeCache.awRecycle }
cooperationFeedback.value = { ...pcHomeCache.cooperationFeedback }
publishOptions.value = pcHomeCache.publishOptions
}
return true
}
function saveToCache() {
const snapshot = getSnapshot()
if (snapshot.listings.length === 0) return
pcHomeCache.saveList({
signature: desktopHomeStateSignature(),
listings: snapshot.listings,
totalListings: snapshot.totalListings,
zoneCounts: snapshot.zoneCounts,
currentPage: snapshot.currentPage,
hasMoreListings: snapshot.hasMoreListings,
})
pcHomeCache.saveHomeConfig({
announcements: announcements.value,
banners: banners.value,
awRecycle: awRecycle.value,
cooperationFeedback: cooperationFeedback.value,
publishOptions: publishOptions.value,
})
pcHomeCache.saveScrollTop(window.scrollY)
}
function savePcHomeScroll() {
saveToCache()
try {
window.sessionStorage.setItem(
pcHomeScrollStorageKey,
JSON.stringify({ signature: desktopHomeStateSignature(), top: window.scrollY })
)
} catch {
// sessionStorage 不可用时忽略,仅损失滚动恢复
// sessionStorage 不可用时忽略
}
}
function restorePcHomeScroll() {
let savedTop = 0
let savedSignature = ''
try {
const raw = window.sessionStorage.getItem(pcHomeScrollStorageKey)
if (raw) {
const parsed = JSON.parse(raw) as { signature?: string; top?: number }
if (typeof parsed.signature === 'string' && typeof parsed.top === 'number') {
savedSignature = parsed.signature
savedTop = parsed.top
let savedTop = pcHomeCache.scrollTop
let savedSignature = pcHomeCache.listSignature
if (savedTop <= 0) {
try {
const raw = window.sessionStorage.getItem(pcHomeScrollStorageKey)
if (raw) {
const parsed = JSON.parse(raw) as { signature?: string; top?: number }
if (typeof parsed.signature === 'string' && typeof parsed.top === 'number') {
savedSignature = parsed.signature
savedTop = parsed.top
}
}
}
} catch {
return
}
// 仅当返回时的筛选条件与离开时一致才恢复,避免把滚动带到别的筛选结果上
if (savedTop <= 0 || savedSignature !== desktopHomeStateSignature()) return
let attempts = 0
const apply = () => {
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
if (savedTop <= maxTop) {
window.scrollTo({ top: savedTop })
} catch {
return
}
// 首页无限滚动分页:已渲染高度不足时继续加载一页后重试,尽量恢复到位
if (attempts < 5 && hasMoreListings.value && !loadingMore.value) {
attempts += 1
void loadListingsPage(false).then(() => requestAnimationFrame(apply))
} else {
window.scrollTo({ top: maxTop })
}
}
if (savedTop <= 0 || savedSignature !== desktopHomeStateSignature()) return
const apply = () => {
window.scrollTo({ top: savedTop, behavior: 'instant' })
}
apply()
requestAnimationFrame(apply)
}
function handleScroll() {
if (window.scrollY > 0) {
pcHomeCache.saveScrollTop(window.scrollY)
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
if (restored) {
restorePcHomeScroll()
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
onBeforeRouteLeave(() => {
savePcHomeScroll()
})
syncHomeQuery()
loadHome()
const restored = restoreFromCache()
if (!restored) {
loadHome()
}
</script>
<template>
@@ -407,18 +463,23 @@ loadHome()
</div>
<div class="hero-section">
<HomeBanner :banners="banners" />
<HomeStats :stats="statCards" />
<HomeBanner :banners="banners" :stats="statCards" />
</div>
<div class="biz-entry-grid" aria-label="业务入口">
<RouterLink class="biz-entry" to="/">
<strong>租号大厅</strong>
<small>高哈夫币 · 安全交接 · 随租随玩</small>
<div class="biz-title-row">
<strong>租号大厅</strong>
<small>安全交接 · 随租随玩</small>
</div>
<van-icon name="arrow" :size="12" class="biz-arrow" />
</RouterLink>
<RouterLink v-if="!siteStore.isDistribution" class="biz-entry crash" to="/crash">
<strong>撞车 <em>NEW</em></strong>
<small>选购商品 · 支付后进群联系客服</small>
<div class="biz-title-row">
<strong>撞车 <em>NEW</em></strong>
<small>选购商品 · 进群交接</small>
</div>
<van-icon name="arrow" :size="12" class="biz-arrow" />
</RouterLink>
<button
v-if="awRecycle.enabled"
@@ -426,8 +487,11 @@ loadHome()
class="biz-entry aw-recycle"
@click="openAWRecycle"
>
<strong>{{ awRecycle.title }}</strong>
<small>{{ awRecycle.subtitle }}</small>
<div class="biz-title-row">
<strong>{{ awRecycle.title }}</strong>
<small>{{ awRecycle.subtitle }}</small>
</div>
<van-icon name="arrow" :size="12" class="biz-arrow" />
</button>
<button
v-if="cooperationFeedback.enabled"
@@ -435,8 +499,11 @@ loadHome()
class="biz-entry cooperation-feedback"
@click="openCooperationFeedback"
>
<strong>{{ cooperationFeedback.title }}</strong>
<small>{{ cooperationFeedback.subtitle }}</small>
<div class="biz-title-row">
<strong>{{ cooperationFeedback.title }}</strong>
<small>{{ cooperationFeedback.subtitle }}</small>
</div>
<van-icon name="arrow" :size="12" class="biz-arrow" />
</button>
</div>
@@ -493,11 +560,13 @@ loadHome()
@reset="handleResetFilters"
/>
<div class="list-head">
<div class="zone-head">
<p class="eyebrow">Account Zone</p>
<h2>账号专区</h2>
</div>
<!-- 专区与排序合并为同一行极简操作条 -->
<div class="list-control-bar">
<HomeZones
:zones="zoneOptions"
:active-zone="activeZone"
@update:active-zone="activeZone = $event"
/>
<div class="list-actions sort-bar" role="group" aria-label="排序方式">
<button
v-for="item in sortOptions"
@@ -512,12 +581,6 @@ loadHome()
</div>
</div>
<HomeZones
:zones="zoneOptions"
:active-zone="activeZone"
@update:active-zone="activeZone = $event"
/>
<div v-if="!loading && listings.length === 0" class="home-empty-state">
<strong>没有符合条件的账号</strong>
<span>可以放宽筛选条件或切回全部专区。</span>
@@ -538,7 +601,7 @@ loadHome()
<style scoped>
.pc-home-redesign {
display: grid;
gap: 20px;
gap: 12px;
width: 100%;
max-width: 1720px;
min-width: 0;
@@ -548,7 +611,12 @@ loadHome()
.home-content {
display: grid;
gap: 20px;
gap: 12px;
min-width: 0;
}
.hero-section {
width: 100%;
min-width: 0;
}
@@ -560,54 +628,85 @@ loadHome()
.biz-entry {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 18px;
border-radius: 14px;
border: 1px solid #eef1f5;
background: #fff;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 52px;
padding: 12px 18px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
text-decoration: none;
text-align: left;
cursor: pointer;
font: inherit;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.biz-entry:hover {
border-color: #ffd4b0;
border-color: #ffbe98;
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.08);
transform: translateY(-2px);
}
.biz-title-row {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.biz-entry strong {
color: #17233d;
font-size: 16px;
color: #0f172a;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
}
.biz-entry small {
color: #7b8798;
font-size: 13px;
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.biz-arrow {
color: #94a3b8;
flex-shrink: 0;
transition:
transform 0.2s ease,
color 0.2s ease;
}
.biz-entry:hover .biz-arrow {
color: #ff6a00;
transform: translateX(2px);
}
.biz-entry.crash strong em {
margin-left: 6px;
padding: 1px 6px;
padding: 2px 6px;
border-radius: 999px;
background: #ff6a00;
color: #fff;
font-size: 11px;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.biz-entry.aw-recycle {
border-color: #e8edff;
background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 100%);
border-color: #e0e7ff;
background: linear-gradient(135deg, #ffffff 0%, #f6f8fe 100%);
}
.biz-entry.cooperation-feedback {
border-color: #d9f0e5;
background: linear-gradient(135deg, #ffffff 0%, #f1fbf6 100%);
border-color: #ccfbf1;
background: linear-gradient(135deg, #ffffff 0%, #f0fdf9 100%);
}
:deep(.aw-recycle-image) {
@@ -621,74 +720,47 @@ loadHome()
@media (max-width: 960px) {
.biz-entry-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
}
}
.infinite-load-state {
padding: 6px 0 18px;
padding: 12px 0 24px;
color: #94a3b8;
font-size: 13px;
font-weight: 700;
font-weight: 600;
text-align: center;
}
.hero-section {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
min-width: 0;
}
.list-head {
/* 专区与排序单行交互条 */
.list-control-bar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.zone-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.eyebrow {
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #ff6a00;
}
.zone-head h2 {
margin: 0;
font-size: 28px;
font-weight: 900;
color: #17233d;
padding: 2px 0;
}
.sort-bar {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
gap: 4px;
align-items: center;
padding: 4px;
border-radius: 12px;
background: #f3f5f9;
padding: 3px;
border-radius: 10px;
background: #f1f5f9;
}
.sort-chip {
min-height: 32px;
padding: 0 14px;
min-height: 28px;
padding: 0 12px;
border: none;
border-radius: 9px;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 700;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s ease,
@@ -697,14 +769,14 @@ loadHome()
}
.sort-chip:hover {
color: #334155;
background: rgba(255, 255, 255, 0.7);
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
}
.sort-chip.active {
background: #ffffff;
color: #ff6a00;
box-shadow: 0 1px 4px rgba(23, 35, 61, 0.08);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.enhanced-desktop-list {
@@ -140,6 +140,8 @@ function timeText(item: ListingCollectionItem) {
v-if="item.listing.cover_url || item.listing.screenshot_urls?.[0]"
:src="item.listing.cover_url || item.listing.screenshot_urls?.[0]"
:alt="getListingTitle(item.listing)"
loading="lazy"
decoding="async"
/>
<div v-else class="mobile-cover-empty">HFB</div>
<div class="mobile-info">
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@ 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 } from '@element-plus/icons-vue'
import { ShoppingCart, Star, StarFilled, ArrowLeft, DocumentCopy } from '@element-plus/icons-vue'
import { goBackOrHome } from '@/shared/utils/routerBack'
import { fetchListing, type PublicListingDetail } from '@/features/listings/api/listings'
import { fetchListing, type Listing } from '@/features/listings/api/listings'
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
import {
createOrder,
@@ -38,6 +38,12 @@ import {
getServerRegion,
readAssetNumber,
readAssetString,
getRatioValue,
isTopTierInsurance,
getScreenshotThumbURL,
getScreenshotMediumURL,
getScreenshotRawURL,
type ListingDisplayResource,
} from '@/shared/utils/listingDisplay'
const route = useRoute()
@@ -56,6 +62,8 @@ 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 &&
@@ -63,7 +71,7 @@ const canCreateOrderAfterAgreement = computed(
virtualAgreementChecked.value &&
renterAgreementChecked.value
)
const listing = ref<PublicListingDetail | null>(null)
const listing = ref<Listing | null>(null)
onMounted(async () => {
loading.value = true
@@ -97,32 +105,59 @@ const orderPriceBreakdown = computed(() => {
}
})
const coverURL = computed(() => {
// 核心资产微卡计算(同首页设计标准)
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 listing.value.cover_url || listing.value.screenshot_urls?.[0] || ''
return getDailyLoss(listing.value)
})
const detailMetrics = computed(() => {
// 平台/区服特性徽标
const accessTags = 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: `¥${orderTotal.value}`, tone: 'price' },
{ label: '押金', value: `¥${formatCent(listing.value.deposit_amount_cent)}`, tone: '' },
]
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)
@@ -133,17 +168,24 @@ const detailScreenshots = computed(() => {
url,
}))
})
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
const detailScreenshotRawUrls = computed(() =>
detailScreenshots.value.map(shot => getScreenshotRawURL(shot.url))
)
function readGroupedScreenshots(item: PublicListingDetail) {
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: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', 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]
@@ -156,30 +198,48 @@ function readGroupedScreenshots(item: PublicListingDetail) {
})
}
// 皮肤清单(带高光品级)
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: '其他皮肤',
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]) => ({
key,
title: titles[key] || key,
options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.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)
@@ -197,10 +257,24 @@ const accountRows = computed(() => {
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
{ label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无' },
{ 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) {
@@ -279,7 +353,7 @@ async function copyListingCode() {
if (!listing.value) return
try {
await navigator.clipboard.writeText(formatListingCode(listing.value))
ElMessage.success('商品编号已复制')
ElMessage.success('商品编号已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
@@ -300,47 +374,33 @@ function updateAgreementReadState(type: 'virtual' | 'renter') {
}
}
// 手动锁定/解锁背景滚动
// 锁定/解锁背景滚动
watch(agreementVisible, visible => {
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
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 listingPrice(item: PublicListingDetail) {
return formatMoney(getListingDisplayPrice(item))
}
function handleToggleFavorite() {
if (!listing.value) return
const added = toggleFavorite(listing.value)
@@ -353,225 +413,438 @@ function goBack() {
</script>
<template>
<section class="pc-detail page" v-loading="loading">
<div class="anti-fraud-strip compact">
<span>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span>
</div>
<div class="detail-back-bar">
<button class="detail-back-btn" type="button" @click="goBack">
<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>
<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">
<section class="pc-detail-main">
<div class="detail-hero-card">
<AuthImage
v-if="coverURL"
:source="coverURL"
:alt="getListingTitle(listing)"
image-class="detail-hero-img"
loading="eager"
/>
<span v-else>HFB ACCOUNT</span>
<div class="detail-hero-overlay">
<div class="detail-tags">
<span>{{ getServerRegion(listing) }}</span>
<span v-if="getLoginMethod(listing)">{{ getLoginMethod(listing) }}</span>
<span v-if="listing.rank_level">{{ listing.rank_level }}</span>
</div>
<button class="detail-code-chip" type="button" @click="copyListingCode">
编号 {{ formatListingCode(listing) }}
</button>
<button class="detail-favorite-chip" type="button" @click="handleToggleFavorite">
<el-icon>
<component :is="listingFavorited ? StarFilled : Star" />
</el-icon>
{{ listingFavorited ? '已收藏' : '收藏账号' }}
</button>
<h1>{{ getListingTitle(listing) }}</h1>
<p>{{ getListingSubtitle(listing) }}</p>
</div>
</div>
<!-- 左侧内容流 -->
<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>
<div class="detail-body">
<div class="detail-summary-row">
<div
v-for="metric in detailMetrics"
:key="metric.label"
class="detail-metric"
:class="`is-${metric.tone}`"
>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
</div>
</div>
<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>
<section class="detail-section">
<div class="detail-section-head">
<h2>账号资料</h2>
<span>{{ formatGameName(listing.game_name, '三角洲') }}</span>
</div>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}:{{ chip.value }}
<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>
<dl class="detail-info-grid">
<div v-for="row in accountRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="detail-section">
<div class="detail-section-head">
<h2>号主备注</h2>
</div>
<p class="detail-description">{{ listing.description || '号主暂未填写详细说明。' }}</p>
</section>
<section v-if="getListingResources(listing).length" class="detail-section">
<div class="detail-section-head">
<h2>额外消耗品</h2>
<span>{{ getListingResources(listing).length }} 项</span>
</div>
<div class="detail-resource-grid">
<div
v-for="resource in getListingResources(listing)"
:key="resource.key"
class="detail-resource-card"
>
<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>
</section>
<section v-if="detailSkinGroups.length" class="detail-section">
<div class="detail-section-head">
<h2>皮肤清单</h2>
<span>按类型展示</span>
</div>
<div class="skin-groups">
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-group">
<h3>{{ group.title }}</h3>
<div class="detail-chip-row">
<span v-for="skin in group.options" :key="skin">{{ skin }}</span>
</div>
</div>
</div>
</section>
<section v-if="detailScreenshots.length" class="detail-section">
<div class="detail-section-head">
<h2>账号截图</h2>
<span>{{ detailScreenshots.length }} 张</span>
</div>
<div class="detail-screenshot-grid">
<figure
v-for="(shot, index) in detailScreenshots"
:key="shot.url"
class="detail-screenshot"
>
<AuthImage
:source="shot.url"
:alt="shot.label"
image-class="detail-screenshot-image"
fallback-class="detail-screenshot-image"
fit="cover"
:preview-src-list="detailScreenshotUrls"
:preview-initial-index="index"
/>
<figcaption>{{ shot.label }}</figcaption>
</figure>
</div>
</section>
</div>
</section>
<aside class="order-panel pc-order-card">
<div class="order-card-head">
<div>
<span class="order-eyebrow">平台担保</span>
<h2>立即下单</h2>
</div>
<span class="order-state">可租</span>
</div>
<p class="order-safe-text">平台托管订单与押金,按平台交接流程完成账号使用。</p>
<el-form label-position="top" class="order-form">
<div class="order-total-box">
<div class="order-total-head">
<span>租赁合计</span>
<strong>¥{{ listingPrice(listing) }}</strong>
</div>
<div class="order-price-breakdown">
<div>
<span>基础租金</span>
<strong>¥{{ formatMoney(orderPriceBreakdown.rent) }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>¥{{ formatMoney(orderPriceBreakdown.consumable) }}</strong>
</div>
</div>
<div class="order-deposit-row">
<span>押金另付</span>
<strong>¥{{ formatCent(listing.deposit_amount_cent) }}</strong>
</div>
</div>
<dl class="order-check-list">
<div>
<dt>账号区服</dt>
<dd>{{ getServerRegion(listing) || '--' }}</dd>
</div>
<div>
<dt>上号方式</dt>
<dd>{{ getLoginMethod(listing) || '--' }}</dd>
</div>
<div>
<dt>预计可租</dt>
<dd>{{ formatEstimatedRentalDuration(listing) }}</dd>
</div>
</dl>
<div class="order-action-group">
<el-button
type="warning"
size="large"
:loading="ordering || agreementsLoading"
class="full-control order-primary-btn"
@click="handleCreateOrder"
>
<el-icon><ShoppingCart /></el-icon>
立即下单
</el-button>
<el-button
class="full-control favorite-order-btn"
size="large"
<button
class="favorite-action-btn"
:class="{ 'is-active': listingFavorited }"
type="button"
@click="handleToggleFavorite"
>
<el-icon>
<el-icon :size="15">
<component :is="listingFavorited ? StarFilled : Star" />
</el-icon>
{{ listingFavorited ? '取消收藏' : '收藏账号' }}
</el-button>
<span>{{ listingFavorited ? '已收藏' : '收藏账号' }}</span>
</button>
</div>
</el-form>
<!-- 标题与副标题 -->
<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="下单协议确认"
@@ -580,7 +853,7 @@ function goBack() {
:close-on-click-modal="false"
>
<div v-if="agreements" class="agreement-dialog-body">
<p class="agreement-tip">请完整阅读并勾选以下两份协议后继续下单。</p>
<p class="agreement-tip">请完整阅读并勾选以下两份协议后继续下单:</p>
<div class="agreement-grid">
<section class="agreement-panel">
<h3>{{ agreements.virtual_asset_purchase.title }}</h3>
@@ -648,149 +648,60 @@
}
.mobile-card {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
column-gap: 8px;
row-gap: 8px;
overflow: hidden;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: inherit;
text-decoration: none;
}
.card-cover {
position: relative;
display: grid;
width: 104px;
height: 104px;
place-items: center;
overflow: hidden;
border-radius: 8px;
background: #e2e8f0;
color: #64748b;
font-weight: 800;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-cover-labels {
position: absolute;
left: 6px;
top: 6px;
display: flex;
gap: 6px;
}
.card-cover-labels em {
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 106, 0, 0.92);
color: #fff;
font-size: 10px;
font-style: normal;
font-weight: 800;
}
.card-main {
display: flex;
min-width: 0;
flex-direction: column;
padding: 0;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #e8edf5;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.03);
text-decoration: none;
color: inherit;
transition:
transform 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.card-title-row h2 {
margin: 0;
display: -webkit-box;
min-height: 35px;
overflow: hidden;
color: #0f172a;
font-size: 13px;
line-height: 1.34;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
.mobile-card:active {
transform: scale(0.99);
border-color: #fed7aa;
}
.card-meta-row {
/* 顶部精简元信息栏 */
.mobile-card-header {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 3px;
}
.mobile-listing-no,
.rental-meta-badge {
display: inline-flex;
width: fit-content;
align-items: center;
min-height: 17px;
padding: 1px 6px;
border-radius: 999px;
background: #f5f7fa;
color: #64748b;
font-size: 10px;
font-weight: 800;
line-height: 1.2;
justify-content: space-between;
gap: 8px;
}
.rental-meta-badge {
gap: 2px;
border: none;
background: #f5f7fa;
color: #64748b;
white-space: nowrap;
}
.meta-separator {
color: #cbd5e1;
}
.card-subtitle {
width: fit-content;
margin: 0;
padding: 1px 6px;
border-radius: 999px;
background: #fff7ed;
color: #ea580c;
font-size: 10px;
font-weight: 900;
line-height: 1.35;
}
.card-action-row {
.header-tags-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-top: 4px;
}
.card-badges-row,
.card-chip-row {
display: flex;
gap: 5px;
flex-wrap: wrap;
gap: 4px;
}
.server-badge,
.card-chip-row span {
.code-badge {
padding: 1px 6px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2px;
}
.server-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
border-radius: 999px;
border: 1px solid transparent;
background: #f1f5f9;
color: #475569;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
line-height: 1.35;
font-weight: 600;
}
.server-badge.tone-wechat {
@@ -800,7 +711,7 @@
.server-badge.tone-qq {
background: #eff6ff;
color: #1683ff;
color: #0284c7;
}
.server-badge.tone-scan {
@@ -818,80 +729,345 @@
color: #4f46e5;
}
.card-chip-row span.tone-coin {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
.server-badge.tone-default {
background: #f1f5f9;
color: #475569;
}
.card-chip-row span.tone-insurance {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
.header-tags-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.card-chip-row span.tone-stamina {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
.aux-pill {
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.card-chip-row span.tone-load {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
.aux-pill.rental {
background: #f0fdf4;
color: #15803d;
border: 1px solid #dcfce7;
}
.card-chip-row span.tone-rank {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
.aux-pill.loss {
background: #fffbeb;
color: #b45309;
border: 1px solid #fef3c7;
}
.card-chip-row span.tone-weapon {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
/* 中部:封面 + 信息流 */
.mobile-card-body {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.card-chip-row span.tone-asset {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
/* 左侧封面 */
.mobile-card-media {
position: relative;
width: 100px;
height: 90px;
border-radius: 8px;
overflow: hidden;
background: #f1f5f9;
flex-shrink: 0;
}
.card-chip-row span.tone-time {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
.mobile-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-footer {
margin-top: 5px;
.media-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 16px;
font-weight: 800;
color: #cbd5e1;
}
.price-col {
.media-badges {
position: absolute;
top: 4px;
left: 4px;
display: flex;
gap: 3px;
z-index: 2;
}
.media-badges .badge {
padding: 1px 4px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
color: #ffffff;
}
.media-badges .badge.sale {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.media-badges .badge.gift {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.media-ratio {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2px 4px;
background: linear-gradient(to top, rgba(15, 23, 42, 0.88), rgba(15, 23, 42, 0.4));
color: #fcd34d;
font-size: 10px;
font-weight: 700;
text-align: center;
backdrop-filter: blur(2px);
z-index: 2;
}
/* 右侧信息流 */
.mobile-card-info {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
/* 核心资产微卡 */
.card-core-assets {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.core-asset-card {
display: inline-flex;
width: fit-content;
max-width: 100%;
align-items: baseline;
gap: 6px;
align-items: center;
gap: 3px;
padding: 2px 6px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.price-col strong {
color: #ef4444;
font-size: 20px;
.core-asset-card.coin-card {
background: #fff9f5;
border-color: #fed7aa;
}
.core-asset-card.slot-card.top-insurance {
background: #fff9f5;
border-color: #fed7aa;
}
.c-label {
font-size: 10px;
color: #64748b;
font-weight: 500;
}
.c-val {
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.c-val.orange {
color: #ea580c;
}
.c-val.slate {
color: #0f172a;
}
.c-val.amber {
color: #b45309;
}
/* 高光皮肤与物资 */
.card-highlights {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.mobile-skin-pill {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
background: #f8fafc;
color: #334155;
border: 1px solid #e2e8f0;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-skin-pill .quality-dot {
width: 5px;
height: 5px;
border-radius: 999px;
}
.mobile-skin-pill.skin-type-melee {
background: #faf5ff;
color: #7e22ce;
border-color: #f3e8ff;
}
.mobile-skin-pill.skin-type-melee .quality-dot {
background: #a855f7;
}
.mobile-skin-pill.skin-type-operatorGold,
.mobile-skin-pill.skin-type-weaponGold {
background: #fffbeb;
color: #b45309;
border-color: #fef3c7;
}
.mobile-skin-pill.skin-type-operatorGold .quality-dot,
.mobile-skin-pill.skin-type-weaponGold .quality-dot {
background: #f59e0b;
}
.mobile-skin-pill.skin-type-operatorRed {
background: #fff1f2;
color: #be123c;
border-color: #ffe4e6;
}
.mobile-skin-pill.skin-type-operatorRed .quality-dot {
background: #f43f5e;
}
.mobile-res-pill {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
border-radius: 4px;
background: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 10px;
color: #334155;
}
.mobile-res-pill .r-label {
color: #64748b;
}
.mobile-res-pill .r-qty {
color: #ea580c;
font-weight: 700;
}
.mobile-res-pill .r-gift {
padding: 0 3px;
border-radius: 2px;
background: #10b981;
color: #ffffff;
font-size: 9px;
font-style: normal;
font-weight: 700;
}
/* 价格与租用行动区 */
.card-price-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-top: 2px;
}
.price-wrap {
display: flex;
align-items: baseline;
gap: 5px;
}
.price-main {
display: flex;
align-items: baseline;
color: #ff5500;
}
.price-main small {
font-size: 12px;
font-weight: 700;
}
.price-main strong {
font-size: 18px;
font-weight: 900;
line-height: 1;
}
.rent-sub {
padding: 1px 6px;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
.deposit-text {
font-size: 10px;
color: #94a3b8;
font-weight: 500;
}
.rent-cta-btn {
display: inline-flex;
align-items: center;
gap: 2px;
height: 24px;
padding: 0 9px;
border-radius: 999px;
background: linear-gradient(135deg, #ff6a00 0%, #ff8533 100%);
color: #ffffff;
font-size: 11px;
font-weight: 700;
box-shadow: 0 2px 6px rgba(255, 106, 0, 0.25);
}
/* 底部次要规格流 */
.card-specs-row {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
padding-top: 6px;
border-top: 1px dashed #f1f5f9;
}
.spec-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
border-radius: 4px;
background: #f8fafc;
border: 1px solid #edf2f7;
font-size: 10px;
}
.spec-chip .k {
color: #94a3b8;
}
.spec-chip .v {
color: #334155;
font-weight: 600;
}
.card-chip-row {
@@ -43,7 +43,6 @@ import {
import {
getListingChips,
getListingDisplayPrice,
getListingTitle,
getDailyLoss,
getRatioValue,
formatListingCode,
@@ -53,6 +52,12 @@ import {
assetRegions,
hasAcceleratedSaleRatio,
hasGiftResources,
isTopTierInsurance,
getCoinWan,
formatHafCoinM,
readAssetString,
getListingSkinGroups,
getListingResources,
} from '@/shared/utils/listingDisplay'
import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache'
import { useDistributionSiteStore } from '@/stores/distributionSite'
@@ -374,13 +379,6 @@ function restoreScrollPosition() {
}
onMounted(() => {
const restoredList = restoreFromCache()
if (!restoredList) {
loadListings()
}
if (!restoreHomeConfigFromCache()) {
loadHomeConfig()
}
if (restoredList) {
restoreScrollPosition()
}
@@ -779,26 +777,55 @@ function getAccessBadgeMeta(value: string) {
return { icon: 'bookmark-o', tone: 'default' }
}
function getMobileRatioText(item: PublicListingItem) {
const ratio = getRatioValue(item)
if (ratio <= 0) return ''
const formatted = Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1)
return `1元=${formatted}w哈夫币`
type Listing = PublicListingItem
function getItemHafCoin(item: Listing) {
return formatHafCoinM(getCoinWan(item))
}
function chipTone(label: string) {
if (label.includes('哈夫币')) return 'coin'
if (label.includes('保险')) return 'insurance'
if (label.includes('体力')) return 'stamina'
if (label.includes('负重')) return 'load'
if (label.includes('段位')) return 'rank'
if (label.includes('AWM')) return 'weapon'
if (label.includes('总资产')) return 'asset'
if (label.includes('方便')) return 'time'
return 'default'
function getItemInsurance(item: Listing) {
return readAssetString(item, 'season_insurance') || '--'
}
function getItemRatioText(item: Listing) {
const val = getRatioValue(item)
if (val <= 0) return null
return `1元=${val.toFixed(0)}w`
}
function getItemSecondaryChips(item: Listing) {
return getListingChips(item).filter(
c => c.label !== '哈夫币' && c.label !== '保险格数' && c.label !== 'AWM'
)
}
function getItemSkinPreview(item: Listing) {
const groups = getListingSkinGroups(item)
const list: Array<{ name: string; type: string }> = []
for (const group of groups) {
for (const opt of group.options) {
list.push({ name: opt, type: group.key })
if (list.length >= 3) return list
}
}
return list
}
function getItemResourcePreview(item: Listing) {
const resources = getListingResources(item)
return resources.slice(0, 2)
}
syncMobileHomeQuery()
// 同步恢复列表和配置快照,确保首帧 DOM 即包含完整卡片列表,避免高度塌陷和滚动闪跳
const restoredList = restoreFromCache()
if (!restoredList) {
loadListings()
}
if (!restoreHomeConfigFromCache()) {
loadHomeConfig()
}
</script>
<template>
@@ -1004,7 +1031,7 @@ syncMobileHomeQuery()
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
</van-empty>
<!-- 列表卡片:全宽上下布局 -->
<!-- 列表卡片:现代结构化卡片(对齐PC首页体验) -->
<div class="mobile-list">
<RouterLink
v-for="item in displayListings"
@@ -1012,81 +1039,147 @@ syncMobileHomeQuery()
class="mobile-card"
:to="`/m/listings/${item.id}`"
>
<div class="card-cover">
<img
v-if="item.cover_url"
:src="item.cover_url"
:alt="getListingTitle(item)"
loading="lazy"
decoding="async"
/>
<span v-else>图</span>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="card-cover-labels"
>
<em v-if="hasGiftResources(item)">有赠送</em>
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
</div>
</div>
<div class="card-main">
<div class="card-title-row">
<h2>{{ getListingTitle(item) }}</h2>
</div>
<div class="card-meta-row">
<div class="mobile-listing-no">{{ formatListingCode(item) }}</div>
<div
v-if="getDailyLoss(item) || formatEstimatedRentalDuration(item) !== '--'"
class="rental-meta-badge"
<!-- 顶部精简元信息栏 -->
<div class="mobile-card-header">
<div class="header-tags-left">
<span class="code-badge">{{ formatListingCode(item) }}</span>
<span
v-if="getServerRegion(item)"
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
>
<span v-if="getDailyLoss(item)">消耗 {{ getDailyLoss(item) }}/天</span>
<span
v-if="getDailyLoss(item) && formatEstimatedRentalDuration(item) !== '--'"
class="meta-separator"
>
·
</span>
<span v-if="formatEstimatedRentalDuration(item) !== '--'">
租期 {{ formatEstimatedRentalDuration(item) }}
</span>
<van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="11" />
<span>{{ getServerRegion(item) }}</span>
</span>
<span
v-if="getLoginMethod(item)"
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
>
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="11" />
<span>{{ getLoginMethod(item) }}</span>
</span>
</div>
<div class="header-tags-right">
<span v-if="formatEstimatedRentalDuration(item) !== '--'" class="aux-pill rental">
可租 {{ formatEstimatedRentalDuration(item) }}
</span>
<span v-if="getDailyLoss(item)" class="aux-pill loss">
耗 {{ getDailyLoss(item) }}/天
</span>
</div>
</div>
<!-- 中部:封面 + 核心资产与高光展台 -->
<div class="mobile-card-body">
<!-- 左侧封面 -->
<div class="mobile-card-media">
<img
v-if="item.cover_url || item.screenshot_urls?.[0]"
:src="item.cover_url || item.screenshot_urls?.[0]"
:alt="formatListingCode(item)"
loading="lazy"
decoding="async"
/>
<div v-else class="media-empty">HFB</div>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="media-badges"
>
<span v-if="hasAcceleratedSaleRatio(item)" class="badge sale">特惠</span>
<span v-if="hasGiftResources(item)" class="badge gift">有赠送</span>
</div>
<div v-if="getRatioValue(item) > 0" class="media-ratio">
比例 1:{{ getRatioValue(item).toFixed(1) }}
</div>
</div>
<div class="card-action-row">
<p v-if="getMobileRatioText(item)" class="card-subtitle">
{{ getMobileRatioText(item) }}
</p>
<div class="card-badges-row">
<span
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
<!-- 右侧信息流 -->
<div class="mobile-card-info">
<!-- 核心资产三大微卡 (对齐PC,3*3橙底高光) -->
<div class="card-core-assets">
<div class="core-asset-card coin-card">
<span class="c-label">哈夫币</span>
<strong class="c-val orange">{{ getItemHafCoin(item) }}</strong>
</div>
<div
class="core-asset-card slot-card"
:class="{ 'top-insurance': isTopTierInsurance(getItemInsurance(item)) }"
>
<van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="13" />
{{ getServerRegion(item) }}
<span class="c-label">保险</span>
<strong
class="c-val"
:class="isTopTierInsurance(getItemInsurance(item)) ? 'orange' : 'slate'"
>{{ getItemInsurance(item) }}</strong
>
</div>
<div v-if="getItemRatioText(item)" class="core-asset-card ratio-card">
<span class="c-label">币比</span>
<strong class="c-val amber">{{ getItemRatioText(item) }}</strong>
</div>
</div>
<!-- 高光皮肤与仓库物资微胶囊 -->
<div
v-if="getItemSkinPreview(item).length || getItemResourcePreview(item).length"
class="card-highlights"
>
<span
v-for="skin in getItemSkinPreview(item)"
:key="skin.name"
class="mobile-skin-pill"
:class="`skin-type-${skin.type}`"
>
<i class="quality-dot" />
<span>{{ skin.name }}</span>
</span>
<span
v-if="getLoginMethod(item)"
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
v-for="res in getItemResourcePreview(item)"
:key="res.key"
class="mobile-res-pill"
>
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="13" />
{{ getLoginMethod(item) }}
<span class="r-label">{{ res.label }}</span>
<strong class="r-qty">{{ res.quantity }}</strong>
<em v-if="res.mode === '赠送'" class="r-gift">送</em>
</span>
</div>
</div>
<div class="card-footer">
<div class="price-col">
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
<span class="rent-sub">押金 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
<!-- 价格与立即租用行动区 -->
<div class="card-price-action">
<div class="price-wrap">
<div class="price-main">
<small>¥</small>
<strong>{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
</div>
<span class="deposit-text">
押金
{{
item.deposit_amount_cent > 0
? `¥${formatCent(item.deposit_amount_cent)}`
: '免押'
}}
</span>
</div>
<div class="rent-cta-btn">
<span>去租用</span>
<van-icon name="arrow" :size="10" />
</div>
</div>
</div>
</div>
<div class="card-chip-row">
<!-- 底部:过滤后的次要规格流 (无多余重复) -->
<div v-if="getItemSecondaryChips(item).length" class="card-specs-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
:class="`tone-${chipTone(chip.label)}`"
v-for="chip in getItemSecondaryChips(item).slice(0, 5)"
:key="chip.label"
class="spec-chip"
>
{{ chip.label }}:{{ chip.value }}
<span class="k">{{ chip.label }}</span>
<strong class="v">{{ chip.value }}</strong>
</span>
</div>
</RouterLink>
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ 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 { goBackOrHome } from '@/shared/utils/routerBack'
import { fetchListing, type PublicListingDetail } from '@/features/listings/api/listings'
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
@@ -13,7 +14,7 @@ import {
} 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 { formatCent, formatMoney, roundMoney } from '@/shared/utils/money'
import {
assetRegions,
formatAssetNumber,
@@ -31,9 +32,15 @@ import {
getListingSubtitle,
getListingTitle,
getLoginMethod,
getOnlineTimeText,
getServerRegion,
readAssetNumber,
readAssetString,
getRatioValue,
isTopTierInsurance,
getScreenshotThumbURL,
getScreenshotMediumURL,
getScreenshotRawURL,
} from '@/shared/utils/listingDisplay'
const route = useRoute()
@@ -75,6 +82,7 @@ onMounted(async () => {
const listingFavorited = computed(() => (listing.value ? isFavorite(listing.value.id) : false))
// 价格拆解与格式化
const orderTotal = computed(() => {
if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value))
@@ -85,44 +93,71 @@ const orderPriceBreakdown = computed(() => {
return {
rent: 0,
consumable: 0,
total: 0,
}
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
total: roundMoney(getListingDisplayPrice(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: '' },
]
// 核心资产微卡计算(完全对齐 PC 首页及详情页设计标准)
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 activeImageIndex = ref(0)
const detailScreenshots = computed(() => {
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
@@ -133,17 +168,24 @@ const detailScreenshots = computed(() => {
url,
}))
})
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.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: 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: '皮肤截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
]
return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
@@ -156,30 +198,83 @@ function readGroupedScreenshots(item: PublicListingDetail) {
})
}
// 皮肤清单(带高光品级)
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: '其他皮肤',
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]) => ({
key,
title: titles[key] || key,
options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.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'
)
})
// 账号基础档案(与 PC 对齐)
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: any) {
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
@@ -293,13 +388,17 @@ function handleToggleFavorite() {
const added = toggleFavorite(listing.value)
showToast({ message: added ? '已收藏账号' : '已取消收藏', icon: added ? 'star' : 'success' })
}
function goBack() {
goBackOrHome(router)
}
</script>
<template>
<main class="mobile-detail">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<button class="back-btn" @click="goBack">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>账号详情</h1>
@@ -318,174 +417,320 @@ function handleToggleFavorite() {
</van-loading>
<template v-else-if="listing">
<!-- 防骗提示 -->
<!-- 平台官方安全保障条 -->
<div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" />
<span>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span>
<van-icon name="shield-o" :size="15" color="#f59e0b" />
<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>
<!-- 1. 账号抬头与核心属性卡片 -->
<div class="info-card header-card">
<div class="header-meta-row">
<div class="meta-tags-wrap">
<button class="mobile-code-chip" type="button" @click="copyListingCode">
<span>{{ formatListingCode(listing) }}</span>
<van-icon name="notes-o" :size="12" />
</button>
<span
v-for="tag in accessTags"
:key="tag.label"
class="platform-pill"
:class="tag.tone"
>
<van-icon :name="tag.icon" :size="12" />
<span>{{ tag.label }}</span>
</span>
<span v-if="listing.rank_level" class="platform-pill rank">
<van-icon name="diamond-o" :size="12" />
<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>
<!-- 标题信息 -->
<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"
<button
class="mobile-favorite-chip"
:class="{ 'is-active': listingFavorited }"
type="button"
@click="handleToggleFavorite"
>
<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>
<van-icon :name="listingFavorited ? 'star' : 'star-o'" :size="14" />
<span>{{ listingFavorited ? '已收藏' : '收藏' }}</span>
</button>
</div>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p v-if="getListingSubtitle(listing)" class="detail-desc">
{{ getListingSubtitle(listing) }}
</p>
<!-- 核心资产四大微卡(完全对标 PC 详情页与首页标准) -->
<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-slate">{{ 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>
</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>
<!-- 2. 验号图片多图展台 (Interactive Gallery Showcase) -->
<div class="info-card gallery-card">
<div class="card-header-row">
<div class="header-left">
<h3 class="card-subtitle">验号图片展台</h3>
<span class="card-subtitle-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="12" />
<span>点击放大查看</span>
</div>
</div>
<div v-else class="stage-empty">
<span>暂无验号截图</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"
<!-- 缩略图胶卷导轨 -->
<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"
>
<AuthImage
:source="shot.url"
:source="getScreenshotThumbURL(shot.url)"
:alt="shot.label"
image-class="screenshot-thumb"
fallback-class="screenshot-thumb"
image-class="thumb-img"
fallback-class="thumb-img"
fit="cover"
:preview-src-list="detailScreenshotUrls"
:preview-initial-index="index"
/>
<figcaption>{{ shot.label }}</figcaption>
</figure>
<span class="thumb-label">{{ shot.label }}</span>
</button>
</div>
</div>
<!-- 留白给底部下单栏 -->
<!-- 3. 账号核心档案参数矩阵 (Specs) -->
<div class="info-card specs-card">
<div class="card-header-row">
<div class="header-left">
<h3 class="card-subtitle">账号核心档案</h3>
<span class="card-subtitle-tip">三角洲行动 详细规格参数</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>
<!-- 账号档案网格 (移动端 2 列对齐) -->
<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>
</div>
<!-- 4. 精选皮肤展台 (带品级圆点与分类) -->
<div v-if="detailSkinGroups.length" class="info-card skin-card">
<div class="card-header-row">
<div class="header-left">
<h3 class="card-subtitle">精选皮肤展台</h3>
<span class="card-subtitle-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>
</div>
<!-- 5. 仓库物资与额外消耗品 -->
<div v-if="resourceItems.length" class="info-card resources-card">
<div class="card-header-row">
<div class="header-left">
<h3 class="card-subtitle">仓库物资与消耗品</h3>
<span class="card-subtitle-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>
</div>
<!-- 6. 号主留言与说明 -->
<div class="info-card note-card">
<div class="card-header-row">
<div class="header-left">
<h3 class="card-subtitle">号主留言与说明</h3>
</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>
</div>
<!-- 7. 平台官方安全保障 -->
<div class="info-card trust-card">
<div class="card-header-row">
<div class="header-left">
<h3 class="card-subtitle">平台官方交易保障</h3>
<span class="card-subtitle-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="18" />
</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="18" />
</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="18" />
</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="18" />
</div>
<div class="trust-text">
<strong>双向实名认证</strong>
<p>买卖双方均公安实名核验,杜绝欺诈</p>
</div>
</div>
</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>
<span class="price-caption">租金合计</span>
<div class="price-total-wrap">
<small class="price-currency">¥</small>
<strong class="price-amount">{{ orderTotal }}</strong>
</div>
</div>
<div class="order-price-detail">
<span>租金 ¥{{ formatMoney(orderPriceBreakdown.rent) }}</span>
<span>额外 ¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span>
<span v-if="orderPriceBreakdown.consumable > 0"
>含物资¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span
>
<span class="deposit-tag">
{{
listing.deposit_amount_cent > 0
? `押金¥${formatCent(listing.deposit_amount_cent)}(退)`
: '免押金'
}}
</span>
</div>
</div>
<van-button
@@ -496,7 +741,8 @@ function handleToggleFavorite() {
loading-text="下单中..."
@click="handleCreateOrder"
>
立即下单
<van-icon name="shopping-cart-o" :size="16" class="order-btn-icon" />
<span>{{ listing.in_transaction ? '交易中' : '立即下单租用' }}</span>
</van-button>
</div>