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, getCoinWan,
formatHafCoinM, formatHafCoinM,
readAssetString, readAssetString,
isTopTierInsurance,
type ListingDisplayResource, type ListingDisplayResource,
} from '@/shared/utils/listingDisplay' } from '@/shared/utils/listingDisplay'
@@ -194,9 +195,15 @@ function formatStatNumber(value: number) {
<span class="asset-label">哈夫币</span> <span class="asset-label">哈夫币</span>
<strong class="asset-value highlight-orange">{{ hafCoinDisplay }}</strong> <strong class="asset-value highlight-orange">{{ hafCoinDisplay }}</strong>
</div> </div>
<div class="asset-card slot-card"> <div
class="asset-card slot-card"
:class="{ 'top-insurance': isTopTierInsurance(insuranceSlot) }"
>
<span class="asset-label">保险格数</span> <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>
<div v-if="ratioDisplay" class="asset-card ratio-card"> <div v-if="ratioDisplay" class="asset-card ratio-card">
<span class="asset-label">折算币比</span> <span class="asset-label">折算币比</span>
@@ -577,6 +584,11 @@ function formatStatNumber(value: number) {
border-color: #e2e8f0; border-color: #e2e8f0;
} }
.slot-card.top-insurance {
background: #fff9f5;
border-color: #fed7aa;
}
.ratio-card { .ratio-card {
background: #f8fafc; background: #f8fafc;
border-color: #e2e8f0; border-color: #e2e8f0;
@@ -307,6 +307,11 @@
border-color: #fed7aa; border-color: #fed7aa;
} }
.slot-tile.top-insurance {
background: #fff9f5;
border-color: #fed7aa;
}
.tile-label { .tile-label {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -39,6 +39,7 @@ import {
readAssetNumber, readAssetNumber,
readAssetString, readAssetString,
getRatioValue, getRatioValue,
isTopTierInsurance,
type ListingDisplayResource, type ListingDisplayResource,
} from '@/shared/utils/listingDisplay' } from '@/shared/utils/listingDisplay'
@@ -112,6 +113,8 @@ const insuranceSlot = computed(() => {
return readAssetString(listing.value, 'season_insurance') || '--' return readAssetString(listing.value, 'season_insurance') || '--'
}) })
const isTopTierInsuranceSlot = computed(() => isTopTierInsurance(insuranceSlot.value))
const ratioValue = computed(() => { const ratioValue = computed(() => {
if (!listing.value) return 0 if (!listing.value) return 0
return getRatioValue(listing.value) return getRatioValue(listing.value)
@@ -479,9 +482,15 @@ function goBack() {
</div> </div>
<!-- 保险格数 --> <!-- 保险格数 -->
<div class="asset-tile slot-tile"> <div
class="asset-tile slot-tile"
:class="{ 'top-insurance': isTopTierInsuranceSlot }"
>
<span class="tile-label">安全箱格数</span> <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> </div>
<!-- 折算币比 --> <!-- 折算币比 -->
@@ -589,149 +589,57 @@
} }
.mobile-card { .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; 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; 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 { .mobile-card:active {
margin: 0; transform: scale(0.99);
display: -webkit-box; border-color: #fed7aa;
min-height: 35px;
overflow: hidden;
color: #0f172a;
font-size: 13px;
line-height: 1.34;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.card-meta-row { /* 顶部精简元信息栏 */
.mobile-card-header {
display: flex; 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; align-items: center;
min-height: 17px; justify-content: space-between;
padding: 1px 6px; gap: 8px;
border-radius: 999px;
background: #f5f7fa;
color: #64748b;
font-size: 10px;
font-weight: 800;
line-height: 1.2;
} }
.rental-meta-badge { .header-tags-left {
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 {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 4px; gap: 5px;
margin-top: 4px;
}
.card-badges-row,
.card-chip-row {
display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px;
} }
.server-badge, .code-badge {
.card-chip-row span { 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; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
padding: 1px 5px; padding: 1px 6px;
border-radius: 999px; border-radius: 4px;
border: 1px solid transparent;
background: #f1f5f9;
color: #475569;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 600;
line-height: 1.35;
} }
.server-badge.tone-wechat { .server-badge.tone-wechat {
@@ -741,7 +649,7 @@
.server-badge.tone-qq { .server-badge.tone-qq {
background: #eff6ff; background: #eff6ff;
color: #1683ff; color: #0284c7;
} }
.server-badge.tone-scan { .server-badge.tone-scan {
@@ -759,80 +667,345 @@
color: #4f46e5; color: #4f46e5;
} }
.card-chip-row span.tone-coin { .server-badge.tone-default {
border-color: #fed7aa; background: #f1f5f9;
background: #fff7ed; color: #475569;
color: #c2410c;
} }
.card-chip-row span.tone-insurance { .header-tags-right {
border-color: #fed7aa; display: flex;
background: #fff7ed; align-items: center;
color: #c2410c; gap: 4px;
flex-shrink: 0;
} }
.card-chip-row span.tone-stamina { .aux-pill {
border-color: #fed7aa; padding: 1px 6px;
background: #fff7ed; border-radius: 4px;
color: #c2410c; font-size: 10px;
font-weight: 700;
white-space: nowrap;
} }
.card-chip-row span.tone-load { .aux-pill.rental {
border-color: #fed7aa; background: #f0fdf4;
background: #fff7ed; color: #15803d;
color: #c2410c; border: 1px solid #dcfce7;
} }
.card-chip-row span.tone-rank { .aux-pill.loss {
border-color: #fed7aa; background: #fffbeb;
background: #fff7ed; color: #b45309;
color: #c2410c; border: 1px solid #fef3c7;
} }
.card-chip-row span.tone-weapon { /* 中部:封面 + 信息流 */
border-color: #fed7aa; .mobile-card-body {
background: #fff7ed; display: grid;
color: #c2410c; grid-template-columns: 100px minmax(0, 1fr);
gap: 10px;
align-items: center;
} }
.card-chip-row span.tone-asset { /* 左侧封面 */
border-color: #fed7aa; .mobile-card-media {
background: #fff7ed; position: relative;
color: #c2410c; width: 100px;
height: 90px;
border-radius: 8px;
overflow: hidden;
background: #f1f5f9;
flex-shrink: 0;
} }
.card-chip-row span.tone-time { .mobile-card-media img {
border-color: #fed7aa; width: 100%;
background: #fff7ed; height: 100%;
color: #c2410c; object-fit: cover;
display: block;
} }
.card-footer { .media-empty {
margin-top: 5px; 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; display: inline-flex;
width: fit-content; align-items: center;
max-width: 100%; gap: 3px;
align-items: baseline; padding: 2px 6px;
gap: 6px; border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
} }
.price-col strong { .core-asset-card.coin-card {
color: #ef4444; background: #fff9f5;
font-size: 20px; 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; font-weight: 900;
line-height: 1; line-height: 1;
} }
.rent-sub { .deposit-text {
padding: 1px 6px;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
font-size: 10px; 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; 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 { .card-chip-row {
@@ -53,6 +53,12 @@ import {
assetRegions, assetRegions,
hasAcceleratedSaleRatio, hasAcceleratedSaleRatio,
hasGiftResources, hasGiftResources,
isTopTierInsurance,
getCoinWan,
formatHafCoinM,
readAssetString,
getListingSkinGroups,
getListingResources,
} from '@/shared/utils/listingDisplay' } from '@/shared/utils/listingDisplay'
import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache' import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
@@ -775,16 +781,41 @@ function getMobileRatioText(item: Listing) {
return `1元=${formatted}w哈夫币` return `1元=${formatted}w哈夫币`
} }
function chipTone(label: string) { function getItemHafCoin(item: Listing) {
if (label.includes('哈夫币')) return 'coin' return formatHafCoinM(getCoinWan(item))
if (label.includes('保险')) return 'insurance' }
if (label.includes('体力')) return 'stamina'
if (label.includes('负重')) return 'load' function getItemInsurance(item: Listing) {
if (label.includes('段位')) return 'rank' return readAssetString(item, 'season_insurance') || '--'
if (label.includes('AWM')) return 'weapon' }
if (label.includes('总资产')) return 'asset'
if (label.includes('方便')) return 'time' function getItemRatioText(item: Listing) {
return 'default' 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() syncMobileHomeQuery()
@@ -979,7 +1010,7 @@ syncMobileHomeQuery()
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button> <van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
</van-empty> </van-empty>
<!-- 列表卡片:全宽上下布局 --> <!-- 列表卡片:现代结构化卡片(对齐PC首页体验) -->
<div class="mobile-list"> <div class="mobile-list">
<RouterLink <RouterLink
v-for="item in displayListings" v-for="item in displayListings"
@@ -987,81 +1018,141 @@ syncMobileHomeQuery()
class="mobile-card" class="mobile-card"
:to="`/m/listings/${item.id}`" :to="`/m/listings/${item.id}`"
> >
<div class="card-cover"> <!-- 顶部精简元信息栏 -->
<img <div class="mobile-card-header">
v-if="item.cover_url" <div class="header-tags-left">
:src="item.cover_url" <span class="code-badge">{{ formatListingCode(item) }}</span>
:alt="getListingTitle(item)" <span
loading="lazy" v-if="getServerRegion(item)"
decoding="async" class="server-badge"
/> :class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
<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"
> >
<span v-if="getDailyLoss(item)">消耗 {{ getDailyLoss(item) }}/天</span> <van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="11" />
<span <span>{{ getServerRegion(item) }}</span>
v-if="getDailyLoss(item) && formatEstimatedRentalDuration(item) !== '--'" </span>
class="meta-separator" <span
> v-if="getLoginMethod(item)"
· class="server-badge"
</span> :class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
<span v-if="formatEstimatedRentalDuration(item) !== '--'"> >
租期 {{ formatEstimatedRentalDuration(item) }} <van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="11" />
</span> <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> </div>
<div class="card-action-row">
<p v-if="getMobileRatioText(item)" class="card-subtitle"> <!-- 右侧信息流 -->
{{ getMobileRatioText(item) }} <div class="mobile-card-info">
</p> <!-- 核心资产三大微卡 (对齐PC,3*3橙底高光) -->
<div class="card-badges-row"> <div class="card-core-assets">
<span <div class="core-asset-card coin-card">
class="server-badge" <span class="c-label">哈夫币</span>
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`" <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" /> <span class="c-label">保险</span>
{{ getServerRegion(item) }} <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>
<span <span
v-if="getLoginMethod(item)" v-for="res in getItemResourcePreview(item)"
class="server-badge" :key="res.key"
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`" class="mobile-res-pill"
> >
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="13" /> <span class="r-label">{{ res.label }}</span>
{{ getLoginMethod(item) }} <strong class="r-qty">{{ res.quantity }}</strong>
<em v-if="res.mode === '赠送'" class="r-gift">送</em>
</span> </span>
</div> </div>
</div>
<div class="card-footer"> <!-- 价格与立即租用行动区 -->
<div class="price-col"> <div class="card-price-action">
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong> <div class="price-wrap">
<span class="rent-sub">押金 ¥{{ formatCent(item.deposit_amount_cent) }}</span> <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>
</div> </div>
<div class="card-chip-row">
<!-- 底部:过滤后的次要规格流 (无多余重复) -->
<div v-if="getItemSecondaryChips(item).length" class="card-specs-row">
<span <span
v-for="chip in getListingChips(item)" v-for="chip in getItemSecondaryChips(item).slice(0, 5)"
:key="`${item.id}-${chip.label}`" :key="chip.label"
:class="`tone-${chipTone(chip.label)}`" class="spec-chip"
> >
{{ chip.label }}:{{ chip.value }} <span class="k">{{ chip.label }}</span>
<strong class="v">{{ chip.value }}</strong>
</span> </span>
</div> </div>
</RouterLink> </RouterLink>
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ import {
} from '@/features/orders/api/orders' } from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import AuthImage from '@/shared/components/business/AuthImage.vue' import AuthImage from '@/shared/components/business/AuthImage.vue'
import { formatCent, formatMoney } from '@/shared/utils/money' import { formatCent, formatMoney, roundMoney } from '@/shared/utils/money'
import { import {
assetRegions, assetRegions,
formatAssetNumber, formatAssetNumber,
@@ -31,9 +31,12 @@ import {
getListingSubtitle, getListingSubtitle,
getListingTitle, getListingTitle,
getLoginMethod, getLoginMethod,
getOnlineTimeText,
getServerRegion, getServerRegion,
readAssetNumber, readAssetNumber,
readAssetString, readAssetString,
getRatioValue,
isTopTierInsurance,
} from '@/shared/utils/listingDisplay' } from '@/shared/utils/listingDisplay'
const route = useRoute() const route = useRoute()
@@ -75,6 +78,7 @@ onMounted(async () => {
const listingFavorited = computed(() => (listing.value ? isFavorite(listing.value.id) : false)) const listingFavorited = computed(() => (listing.value ? isFavorite(listing.value.id) : false))
// 价格拆解与格式化
const orderTotal = computed(() => { const orderTotal = computed(() => {
if (!listing.value) return '0.0' if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value)) return formatMoney(getListingDisplayPrice(listing.value))
@@ -85,44 +89,71 @@ const orderPriceBreakdown = computed(() => {
return { return {
rent: 0, rent: 0,
consumable: 0, consumable: 0,
total: 0,
} }
} }
return { return {
rent: getListingRentPrice(listing.value), rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value), consumable: getListingConsumablePrice(listing.value),
total: roundMoney(getListingDisplayPrice(listing.value)),
} }
}) })
const detailMetrics = computed(() => { // 核心资产微卡计算(完全对齐 PC 首页及详情页设计标准)
if (!listing.value) return [] const hafCoinDisplay = computed(() => {
const dailyLoss = getDailyLoss(listing.value) if (!listing.value) return '--'
const secretKD = readAssetNumber(listing.value, 'secret_kd') return formatHafCoinM(getCoinWan(listing.value))
return [
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: '绝密KD',
value: secretKD > 0 ? formatAssetNumber(secretKD) : '--',
tone: 'coin',
},
{
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{
label: '预计可租',
value: formatEstimatedRentalDuration(listing.value),
tone: 'coin',
},
{
label: '价格',
value: `¥${formatMoney(getListingDisplayPrice(listing.value))}`,
tone: 'price',
},
{ label: '押金', value: `¥${formatCent(listing.value.deposit_amount_cent)}`, tone: '' },
]
}) })
const 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(() => { const detailScreenshots = computed(() => {
if (!listing.value) return [] if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value) const groupedScreenshots = readGroupedScreenshots(listing.value)
@@ -135,15 +166,20 @@ const detailScreenshots = computed(() => {
}) })
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url)) 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) { function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return [] if (typeof groups !== 'object' || groups === null) return []
const slots = [ const slots = [
{ key: 'coin', label: '纯币截图' }, { key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' }, { key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' }, { key: 'skin', label: '皮肤截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
] ]
return slots.flatMap(slot => { return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key] const urls = (groups as Record<string, unknown>)[slot.key]
@@ -156,30 +192,84 @@ function readGroupedScreenshots(item: Listing) {
}) })
} }
// 皮肤清单(带高光品级)
const detailSkinGroups = computed(() => { const detailSkinGroups = computed(() => {
if (!listing.value) return [] if (!listing.value) return []
const groups = listing.value.asset_summary?.skin_groups const groups = listing.value.asset_summary?.skin_groups
if (typeof groups !== 'object' || groups === null) return [] if (typeof groups !== 'object' || groups === null) return []
const titles: Record<string, string> = { const titles: Record<string, { title: string; type: string }> = {
melee: '近战皮肤', melee: { title: '近战皮肤', type: 'melee' },
operator: '干员皮肤', operatorRed: { title: '干员红皮', type: 'operatorRed' },
operatorGold: '干员金皮', operatorGold: { title: '干员金皮', type: 'operatorGold' },
operatorRed: '干员红皮', operator: { title: '干员皮肤', type: 'operator' },
weapon: '武器皮肤', weapon: { title: '武器皮肤', type: 'weapon' },
other: '其他皮肤', other: { title: '其他皮肤', type: 'other' },
imported: '其他皮肤', imported: { title: '其他皮肤', type: 'other' },
} }
return Object.entries(groups as Record<string, unknown>) return Object.entries(groups as Record<string, unknown>)
.map(([key, value]) => ({ .map(([key, value]) => {
key, const meta = titles[key] || { title: key, type: 'other' }
title: titles[key] || key, return {
options: Array.isArray(value) key,
? value.filter((skin): skin is string => typeof skin === 'string') type: meta.type,
: [], title: meta.title,
})) options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}
})
.filter(group => group.options.length) .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() { async function handleCreateOrder() {
if (!listing.value) return if (!listing.value) return
@@ -318,174 +408,307 @@ function handleToggleFavorite() {
</van-loading> </van-loading>
<template v-else-if="listing"> <template v-else-if="listing">
<!-- 防骗提示 --> <!-- 平台官方安全保障条 -->
<div class="fraud-tip"> <div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" /> <van-icon name="shield-o" :size="15" color="#f59e0b" />
<span>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span> <span>平台官方担保:下单后请按平台交接流程确认收号与归还,切勿脱离平台私下交易。</span>
</div> </div>
<!-- 封面图 --> <!-- 1. 账号抬头与核心属性卡片 -->
<div class="cover-area"> <div class="info-card header-card">
<AuthImage <div class="header-meta-row">
v-if="detailScreenshots[0]?.url" <div class="meta-tags-wrap">
:source="detailScreenshots[0].url" <button class="mobile-code-chip" type="button" @click="copyListingCode">
:alt="getListingTitle(listing)" <span>{{ formatListingCode(listing) }}</span>
image-class="cover-img" <van-icon name="notes-o" :size="12" />
fit="cover" </button>
loading="eager" <span v-for="tag in accessTags" :key="tag.label" class="platform-pill" :class="tag.tone">
:preview-src-list="detailScreenshotUrls" <van-icon :name="tag.icon" :size="12" />
/> <span>{{ tag.label }}</span>
<div v-else class="cover-placeholder"> </span>
<van-icon name="photo-o" :size="40" color="#ccc" /> <span v-if="listing.rank_level" class="platform-pill rank">
<span>暂无截图</span> <van-icon name="diamond-o" :size="12" />
</div> <span>{{ listing.rank_level }}</span>
<!-- 截图指示器 --> </span>
<div v-if="detailScreenshots.length > 1" class="cover-count"> <span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
{{ detailScreenshots.length }}张 <i class="status-dot" />
</div> <span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
</div> </span>
</div>
<!-- 标题信息 --> <button
<div class="info-card"> class="mobile-favorite-chip"
<div class="info-tag-row"> :class="{ 'is-active': listingFavorited }"
<van-tag plain type="primary" size="medium">{{ getServerRegion(listing) }}</van-tag> type="button"
<van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium"> @click="handleToggleFavorite"
{{ getLoginMethod(listing) }}
</van-tag>
<van-tag v-if="listing.rank_level" plain size="medium">{{ listing.rank_level }}</van-tag>
</div>
<button class="mobile-code-chip" type="button" @click="copyListingCode">
{{ formatListingCode(listing) }}
</button>
<button class="mobile-favorite-chip" type="button" @click="handleToggleFavorite">
<van-icon :name="listingFavorited ? 'star' : 'star-o'" :size="15" />
{{ listingFavorited ? '已收藏' : '收藏账号' }}
</button>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
</div>
<!-- 资产指标 -->
<div class="metric-row">
<div v-for="metric in detailMetrics" :key="metric.label" class="metric-item">
<span class="metric-label">{{ metric.label }}</span>
<strong class="metric-value" :class="metric.tone">{{ metric.value }}</strong>
</div>
</div>
<!-- 基础信息 -->
<div class="info-card">
<h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
<div class="info-line">
<span class="info-label">烽火等级</span>
<span class="info-text">
{{
readAssetNumber(listing, 'fire_level') > 0
? `${readAssetNumber(listing, 'fire_level')}级`
: '--'
}}
</span>
</div>
<div class="info-line">
<span class="info-label">M单价</span>
<span class="info-text">{{ formatRatio(listing) }}</span>
</div>
<div class="info-line">
<span class="info-label">绝密KD</span>
<span class="info-text">
{{
readAssetNumber(listing, 'secret_kd') > 0
? formatAssetNumber(readAssetNumber(listing, 'secret_kd'))
: '--'
}}
</span>
</div>
<div class="info-line">
<span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join('、') || '--' }}</span>
</div>
<div v-if="readAssetString(listing, 'ban_record')" class="info-line">
<span class="info-label">封禁记录</span>
<span class="info-text">{{ readAssetString(listing, 'ban_record') }}</span>
</div>
</div>
<div v-if="listing.description" class="info-card">
<h3 class="card-subtitle">号主备注</h3>
<p class="detail-desc">{{ listing.description }}</p>
</div>
<div v-if="getListingResources(listing).length" class="info-card">
<h3 class="card-subtitle">额外消耗品</h3>
<div class="resource-grid">
<div
v-for="resource in getListingResources(listing)"
:key="resource.key"
class="resource-pill"
> >
<span>{{ resource.label }}</span> <van-icon :name="listingFavorited ? 'star' : 'star-o'" :size="14" />
<strong>{{ resource.quantity }}</strong> <span>{{ listingFavorited ? '已收藏' : '收藏' }}</span>
<em> </button>
<b>{{ resource.mode || '--' }}</b> </div>
<small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small> <h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<small v-else>无额外收费</small> <p v-if="getListingSubtitle(listing)" class="detail-desc">{{ getListingSubtitle(listing) }}</p>
</em>
<!-- 核心资产四大微卡(完全对标 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>
</div> </div>
<div v-if="detailSkinGroups.length" class="info-card"> <!-- 2. 验号图片多图展台 (Interactive Gallery Showcase) -->
<h3 class="card-subtitle">皮肤</h3> <div class="info-card gallery-card">
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-detail-group"> <div class="card-header-row">
<p>{{ group.title }}</p> <div class="header-left">
<div class="detail-chip-row"> <h3 class="card-subtitle">验号图片展台</h3>
<span v-for="skin in group.options" :key="skin">{{ skin }}</span> <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>
</div>
<!-- 截图列表 --> <!-- 缩略图胶卷导轨 -->
<div v-if="detailScreenshots.length" class="info-card"> <div v-if="detailScreenshots.length > 1" class="thumbnail-filmstrip">
<h3 class="card-subtitle">账号截图</h3> <button
<div class="screenshot-grid"> v-for="(shot, idx) in detailScreenshots"
<figure :key="shot.url + idx"
v-for="(shot, index) in detailScreenshots" type="button"
:key="shot.url" class="thumbnail-item"
class="screenshot-item" :class="{ 'is-active': idx === activeImageIndex }"
@click="activeImageIndex = idx"
> >
<AuthImage <AuthImage
:source="shot.url" :source="shot.url"
:alt="shot.label" :alt="shot.label"
image-class="screenshot-thumb" image-class="thumb-img"
fallback-class="screenshot-thumb" fallback-class="thumb-img"
fit="cover" fit="cover"
:preview-src-list="detailScreenshotUrls"
:preview-initial-index="index"
/> />
<figcaption>{{ shot.label }}</figcaption> <span class="thumb-label">{{ shot.label }}</span>
</figure> </button>
</div> </div>
</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="bottom-spacer"></div>
<!-- 底部下单栏(固定) --> <!-- 底部下单栏(更专业的设计:支持押金提示与明细展开) -->
<div class="order-bar"> <div class="order-bar">
<div class="order-bar-left"> <div class="order-bar-left">
<div class="order-price"> <div class="order-price">
<span class="price-label">价格</span> <span class="price-caption">租金合计</span>
<span class="price-amount">¥{{ orderTotal }}</span> <div class="price-total-wrap">
<small class="price-currency">¥</small>
<strong class="price-amount">{{ orderTotal }}</strong>
</div>
</div> </div>
<div class="order-price-detail"> <div class="order-price-detail">
<span>租金 ¥{{ formatMoney(orderPriceBreakdown.rent) }}</span> <span v-if="orderPriceBreakdown.consumable > 0">含物资¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span>
<span>额外 ¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span> <span class="deposit-tag">
{{ listing.deposit_amount_cent > 0 ? `押金¥${formatCent(listing.deposit_amount_cent)}(退)` : '免押金' }}
</span>
</div> </div>
</div> </div>
<van-button <van-button
@@ -497,7 +720,8 @@ function handleToggleFavorite() {
loading-text="下单中..." loading-text="下单中..."
@click="handleCreateOrder" @click="handleCreateOrder"
> >
{{ listing.in_transaction ? '交易中' : '立即下单' }} <van-icon name="shopping-cart-o" :size="16" class="order-btn-icon" />
<span>{{ listing.in_transaction ? '交易中' : '立即下单租用' }}</span>
</van-button> </van-button>
</div> </div>
@@ -401,3 +401,10 @@ function formatCompactNumber(value: number) {
const rounded = Math.round(value * 10) / 10 const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1) 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格')
}