merge(main): 同步主站最新 UI 与审核优化代码并保持分销多租户隔离
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user