feat(mobile): 全面重构移动端首页与详情页布局并统一3*3顶级保险高光视觉

This commit is contained in:
yml2213
2026-09-06 19:49:33 +08:00
parent 5b072e359b
commit 1c922b239c
8 changed files with 1712 additions and 710 deletions
@@ -21,6 +21,7 @@ import {
getCoinWan,
formatHafCoinM,
readAssetString,
isTopTierInsurance,
type ListingDisplayResource,
} from '@/shared/utils/listingDisplay'
@@ -194,9 +195,15 @@ function formatStatNumber(value: number) {
<span class="asset-label">哈夫币</span>
<strong class="asset-value highlight-orange">{{ hafCoinDisplay }}</strong>
</div>
<div class="asset-card slot-card">
<div
class="asset-card slot-card"
:class="{ 'top-insurance': isTopTierInsurance(insuranceSlot) }"
>
<span class="asset-label">保险格数</span>
<strong class="asset-value highlight-blue">{{ insuranceSlot }}</strong>
<strong
class="asset-value"
:class="isTopTierInsurance(insuranceSlot) ? 'highlight-orange' : 'highlight-blue'"
>{{ insuranceSlot }}</strong>
</div>
<div v-if="ratioDisplay" class="asset-card ratio-card">
<span class="asset-label">折算币比</span>
@@ -577,6 +584,11 @@ function formatStatNumber(value: number) {
border-color: #e2e8f0;
}
.slot-card.top-insurance {
background: #fff9f5;
border-color: #fed7aa;
}
.ratio-card {
background: #f8fafc;
border-color: #e2e8f0;
@@ -307,6 +307,11 @@
border-color: #fed7aa;
}
.slot-tile.top-insurance {
background: #fff9f5;
border-color: #fed7aa;
}
.tile-label {
font-size: 12px;
font-weight: 500;
@@ -39,6 +39,7 @@ import {
readAssetNumber,
readAssetString,
getRatioValue,
isTopTierInsurance,
type ListingDisplayResource,
} from '@/shared/utils/listingDisplay'
@@ -112,6 +113,8 @@ const insuranceSlot = computed(() => {
return readAssetString(listing.value, 'season_insurance') || '--'
})
const isTopTierInsuranceSlot = computed(() => isTopTierInsurance(insuranceSlot.value))
const ratioValue = computed(() => {
if (!listing.value) return 0
return getRatioValue(listing.value)
@@ -479,9 +482,15 @@ function goBack() {
</div>
<!-- 保险格数 -->
<div class="asset-tile slot-tile">
<div
class="asset-tile slot-tile"
:class="{ 'top-insurance': isTopTierInsuranceSlot }"
>
<span class="tile-label">安全箱格数</span>
<strong class="tile-value text-slate">{{ insuranceSlot }}</strong>
<strong
class="tile-value"
:class="isTopTierInsuranceSlot ? 'text-orange' : 'text-slate'"
>{{ insuranceSlot }}</strong>
</div>
<!-- 折算币比 -->
@@ -589,149 +589,57 @@
}
.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 {
@@ -741,7 +649,7 @@
.server-badge.tone-qq {
background: #eff6ff;
color: #1683ff;
color: #0284c7;
}
.server-badge.tone-scan {
@@ -759,80 +667,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 {
@@ -53,6 +53,12 @@ import {
assetRegions,
hasAcceleratedSaleRatio,
hasGiftResources,
isTopTierInsurance,
getCoinWan,
formatHafCoinM,
readAssetString,
getListingSkinGroups,
getListingResources,
} from '@/shared/utils/listingDisplay'
import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache'
import { useSessionStore } from '@/stores/session'
@@ -775,16 +781,41 @@ function getMobileRatioText(item: Listing) {
return `1元=${formatted}w哈夫币`
}
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 getItemHafCoin(item: Listing) {
return formatHafCoinM(getCoinWan(item))
}
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()
@@ -979,7 +1010,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"
@@ -987,81 +1018,141 @@ 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
@@ -13,7 +13,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 +31,12 @@ import {
getListingSubtitle,
getListingTitle,
getLoginMethod,
getOnlineTimeText,
getServerRegion,
readAssetNumber,
readAssetString,
getRatioValue,
isTopTierInsurance,
} from '@/shared/utils/listingDisplay'
const route = useRoute()
@@ -75,6 +78,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 +89,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)
@@ -135,15 +166,20 @@ const detailScreenshots = computed(() => {
})
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
const activeScreenshot = computed(() => {
if (detailScreenshots.value.length === 0) return null
return detailScreenshots.value[activeImageIndex.value] || detailScreenshots.value[0]
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
const slots = [
{ key: 'coin', label: '纯币截图' },
{ key: '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 +192,84 @@ function readGroupedScreenshots(item: Listing) {
})
}
// 皮肤清单(带高光品级)
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
@@ -318,174 +408,307 @@ 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="activeScreenshot.url"
:alt="activeScreenshot.label"
image-class="stage-image"
fallback-class="stage-image"
fit="contain"
:preview-src-list="detailScreenshotUrls"
: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"
: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
@@ -497,7 +720,8 @@ function handleToggleFavorite() {
loading-text="下单中..."
@click="handleCreateOrder"
>
{{ listing.in_transaction ? '交易中' : '立即下单' }}
<van-icon name="shopping-cart-o" :size="16" class="order-btn-icon" />
<span>{{ listing.in_transaction ? '交易中' : '立即下单租用' }}</span>
</van-button>
</div>
@@ -401,3 +401,10 @@ function formatCompactNumber(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
export function isTopTierInsurance(value: string | undefined | null): boolean {
if (!value) return false
const s = String(value).trim().toLowerCase()
return s.includes('3*3') || s.includes('3x3') || s.includes('3×3') || s === '9格' || s.includes('9格')
}