feat(listings): 订单和账号详情展示可租时长与每日消耗

租客在订单列表和详情标题能看到每个号的租期、消耗。账号详情把这两项拆成独立格子。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-05 16:32:08 +08:00
co-authored by Cursor
parent c559287ee4
commit f2060c19cf
18 changed files with 393 additions and 71 deletions
@@ -284,14 +284,18 @@
color: #64748b;
}
/* 核心资产四大微卡 */
/* 核心资产:有币比时五个并排,没有币比时四个并排 */
.core-assets-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.core-assets-bar.has-ratio {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.asset-tile {
display: flex;
flex-direction: column;
@@ -312,6 +316,16 @@
border-color: #fed7aa;
}
.rental-tile {
background: #f0fdf4;
border-color: #86efac;
}
.loss-tile {
background: #fff7ed;
border-color: #fdba74;
}
.tile-label {
font-size: 12px;
font-weight: 500;
@@ -325,6 +339,10 @@
letter-spacing: -0.02em;
}
.core-assets-bar.has-ratio .tile-value {
font-size: 20px;
}
.text-orange {
color: #ea580c;
}
@@ -337,32 +355,12 @@
color: #475569;
}
.tile-subvalues {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.val-pill {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
}
.val-pill.green {
background: #f0fdf4;
border: 1px solid #dcfce7;
.text-green {
color: #15803d;
}
.val-pill.warm {
background: #fffbeb;
border: 1px solid #fef3c7;
color: #b45309;
.text-warm {
color: #c2410c;
}
/* ---------- 2. 验号图片展台 (Gallery Card) ---------- */
@@ -1179,8 +1177,22 @@
align-items: flex-start;
}
.core-assets-bar {
grid-template-columns: repeat(2, 1fr);
.core-assets-bar,
.core-assets-bar.has-ratio {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.core-assets-bar .asset-tile {
grid-column: span 3;
}
.core-assets-bar.has-ratio .asset-tile {
grid-column: span 2;
}
.core-assets-bar.has-ratio .rental-tile,
.core-assets-bar.has-ratio .loss-tile {
grid-column: span 3;
}
.specs-grid {
@@ -489,8 +489,8 @@ function goBack() {
</p>
</div>
<!-- 核心资产四大微卡(完全对齐首页) -->
<div class="core-assets-bar">
<!-- 核心资产:哈夫币、安全箱、币比、可租、消耗 -->
<div class="core-assets-bar" :class="{ 'has-ratio': ratioDisplay }">
<!-- 哈夫币 -->
<div class="asset-tile coin-tile">
<span class="tile-label">哈夫币资产</span>
@@ -513,13 +513,16 @@ function goBack() {
<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>
<span class="tile-label">可租</span>
<strong class="tile-value text-green">{{ rentalDuration }}</strong>
</div>
<div class="asset-tile loss-tile">
<span class="tile-label">消耗</span>
<strong class="tile-value text-warm">{{
dailyLoss ? `${dailyLoss}/天` : '--'
}}</strong>
</div>
</div>
</section>
@@ -262,14 +262,27 @@
line-height: 1.5;
}
/* 核心资产四大微卡 (2x2 网格对齐移动端体验) */
/* 核心资产:有币比时上三下二,没有币比时两列 */
.core-assets-bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.core-assets-bar.has-ratio {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.core-assets-bar.has-ratio .asset-tile {
grid-column: span 2;
}
.core-assets-bar.has-ratio .rental-tile,
.core-assets-bar.has-ratio .loss-tile {
grid-column: span 3;
}
.asset-tile {
display: flex;
flex-direction: column;
@@ -290,6 +303,16 @@
border-color: #fed7aa;
}
.rental-tile {
background: #f0fdf4;
border-color: #86efac;
}
.loss-tile {
background: #fff7ed;
border-color: #fdba74;
}
.tile-label {
font-size: 11px;
font-weight: 500;
@@ -303,6 +326,10 @@
letter-spacing: -0.02em;
}
.core-assets-bar.has-ratio .tile-value {
font-size: 16px;
}
.text-orange {
color: #ea580c;
}
@@ -311,32 +338,12 @@
color: #0f172a;
}
.tile-subvalues {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.val-pill {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
}
.val-pill.green {
background: #f0fdf4;
border: 1px solid #dcfce7;
.text-green {
color: #15803d;
}
.val-pill.warm {
background: #fffbeb;
border: 1px solid #fef3c7;
color: #b45309;
.text-warm {
color: #c2410c;
}
/* ========== 2. 验号多图互动展台 (Gallery Stage) ========== */
@@ -466,8 +466,8 @@ function goBack() {
{{ getListingSubtitle(listing) }}
</p>
<!-- 核心资产四大微卡(完全对标 PC 详情页与首页标准) -->
<div class="core-assets-bar">
<!-- 核心资产:哈夫币、安全箱、币比、可租、消耗 -->
<div class="core-assets-bar" :class="{ 'has-ratio': ratioDisplay }">
<!-- 哈夫币 -->
<div class="asset-tile coin-tile">
<span class="tile-label">哈夫币资产</span>
@@ -490,13 +490,14 @@ function goBack() {
<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>
<span class="tile-label">可租</span>
<strong class="tile-value text-green">{{ rentalDuration }}</strong>
</div>
<div class="asset-tile loss-tile">
<span class="tile-label">消耗</span>
<strong class="tile-value text-warm">{{ dailyLoss ? `${dailyLoss}/天` : '--' }}</strong>
</div>
</div>
</div>
@@ -89,6 +89,8 @@ export interface UserOrderListItem {
title: string
server_region: string
login_platform: string
rental_days?: number
daily_loss_m?: number
display_amount_cent: number
deposit_amount_cent: number
deposit_waived_amount_cent: number
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import type { Order } from '../api/orders'
import { orderListRentalTerms, orderRentalTerms } from './useOrderSnapshot'
describe('orderRentalTerms', () => {
it('estimates rental days from the account snapshot', () => {
const order = {
account_snapshot: {
haf_coin_amount: 100_000_000,
asset_summary: { daily_loss_m: 10 },
},
} as Order
expect(orderRentalTerms(order)).toEqual({ rentalDays: 10, dailyLossText: '10M/天' })
})
it('hides terms when the snapshot has no daily loss', () => {
expect(
orderRentalTerms({ account_snapshot: { haf_coin_amount: 100_000_000 } } as Order)
).toEqual({ rentalDays: 0, dailyLossText: '' })
})
})
describe('orderListRentalTerms', () => {
it('formats list fields the same way', () => {
expect(orderListRentalTerms({ rental_days: 10, daily_loss_m: 10 })).toEqual({
rentalDays: 10,
dailyLossText: '10M/天',
})
})
})
@@ -218,6 +218,34 @@ export function getSnapshotHafCoinM(order: Order | null) {
return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
}
export function formatDailyLossText(dailyLossM: number) {
const rounded = Math.round(Number(dailyLossM || 0) * 10) / 10
if (rounded <= 0) return ''
const text = Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
return `${text}M/天`
}
export function orderRentalTerms(order: Order | null) {
const dailyLossM = readNumber(readAssetSummary(order)?.daily_loss_m)
const coinM = getSnapshotHafCoinM(order)
if (dailyLossM <= 0 || coinM <= 0) return { rentalDays: 0, dailyLossText: '' }
return {
rentalDays: Math.max(1, Math.ceil(coinM / dailyLossM)),
dailyLossText: formatDailyLossText(dailyLossM),
}
}
export function orderListRentalTerms(
item?: { rental_days?: number; daily_loss_m?: number } | null
) {
const rentalDays = Math.ceil(readNumber(item?.rental_days))
const dailyLossText = formatDailyLossText(readNumber(item?.daily_loss_m))
return {
rentalDays: rentalDays > 0 ? rentalDays : 0,
dailyLossText,
}
}
export function orderEstimatedEndAt(order: Order | null) {
if (order?.estimated_end_at) return order.estimated_end_at
const rentedAt = order?.rented_at
@@ -93,7 +93,35 @@
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 14px 0;
margin: 0 0 14px;
}
.order-title:has(+ .order-terms) {
margin-bottom: 0;
}
.order-terms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 14px;
}
.order-terms span {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.order-terms .term-rental {
background: #16a34a;
color: #fff;
}
.order-terms .term-loss {
background: #f97316;
color: #fff;
}
.order-meta-grid {
@@ -10,7 +10,7 @@ import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutS
import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
import { amountYuan, money } from '@/features/orders/composables/useOrderSnapshot'
import { amountYuan, money, orderRentalTerms } from '@/features/orders/composables/useOrderSnapshot'
import {
OrderAccountSnapshot,
OrderCheckoutSummary,
@@ -119,6 +119,8 @@ const {
onCreateDisputeSuccess: () => (showDisputePopup.value = false),
})
const rentalTerms = computed(() => orderRentalTerms(order.value))
// Mobile 独有 UI 状态:各类 popup 显隐。
const showDisputePopup = ref(false)
const showCounterPopup = ref(false)
@@ -204,6 +206,14 @@ async function copyListingCode() {
商品编号 {{ listingCode }}
</button>
<h2 class="order-title">{{ order.title }}</h2>
<p v-if="rentalTerms.rentalDays || rentalTerms.dailyLossText" class="order-terms">
<span v-if="rentalTerms.rentalDays" class="term-rental"
>租期 {{ rentalTerms.rentalDays }}天</span
>
<span v-if="rentalTerms.dailyLossText" class="term-loss"
>消耗 {{ rentalTerms.dailyLossText }}</span
>
</p>
<div class="order-meta-grid">
<div class="meta-item">
<span class="meta-label">{{ orderAmountLabel }}</span>
@@ -11,6 +11,7 @@ import {
startOrderPayment,
type UserOrderListItem,
} from '@/features/orders/api/orders'
import { orderListRentalTerms } from '@/features/orders/composables/useOrderSnapshot'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
@@ -207,6 +208,17 @@ function bizTypeLabel(bizType: BizType) {
return bizType === 'crash' ? '撞车' : '租赁'
}
function rentalTermChips(order?: UserOrderListItem | null) {
const terms = orderListRentalTerms(order)
const chips: Array<{ label: string; value: string; tone: string }> = []
if (terms.rentalDays > 0) {
chips.push({ label: '租期', value: `${terms.rentalDays}天`, tone: 'term-rental' })
}
if (terms.dailyLossText)
chips.push({ label: '消耗', value: terms.dailyLossText, tone: 'term-loss' })
return chips
}
function goDetail(item: UnifiedOrderItem) {
if (item.bizType === 'crash') {
router.push(`/m/crash/orders/${item.id}`)
@@ -381,6 +393,15 @@ async function copyListingCode(order: UserOrderListItem) {
商品编号 {{ formatListingCode(item.rental) }}
</button>
<h3 class="order-title">{{ item.title }}</h3>
<p v-if="rentalTermChips(item.rental).length" class="rental-terms">
<span
v-for="chip in rentalTermChips(item.rental)"
:key="chip.label"
:class="chip.tone"
>
{{ chip.label }} {{ chip.value }}
</span>
</p>
<div class="tag-row">
<span class="info-tag">{{ item.rental.server_region }}</span>
<span class="info-tag">{{ item.rental.login_platform }}</span>
@@ -771,6 +792,31 @@ async function copyListingCode(order: UserOrderListItem) {
line-height: 1.4;
}
.rental-terms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: -2px 0 8px;
}
.rental-terms span {
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 18px;
}
.rental-terms .term-rental {
background: #16a34a;
color: #fff;
}
.rental-terms .term-loss {
background: #f97316;
color: #fff;
}
.tag-row {
display: flex;
gap: 6px;
@@ -30,6 +30,30 @@
line-height: 1.25;
}
.order-terms {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 0;
}
.order-terms span {
padding: 3px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 800;
}
.order-terms .term-rental {
background: #16a34a;
color: #fff;
}
.order-terms .term-loss {
background: #f97316;
color: #fff;
}
.order-meta {
color: #64748b;
margin: 6px 0 10px;
@@ -7,6 +7,7 @@ import { ChatDotRound, CopyDocument, Loading, Service } from '@element-plus/icon
import {
amountYuan,
money,
orderRentalTerms,
OrderAccountSnapshot,
OrderCheckoutSummary,
OrderHandoffTimeline,
@@ -121,6 +122,8 @@ const {
onCreateDisputeSuccess: closeDisputeDialog,
})
const rentalTerms = computed(() => orderRentalTerms(order.value))
// 支付弹窗别名:保持 template 字段名不变。
const paymentDialogVisible = cashier.paymentPopupVisible
const activePayment = cashier.activePayment
@@ -294,6 +297,14 @@ watch([() => route.query.focus, order, loading], () => {
<div class="header-info">
<p class="eyebrow">订单号:{{ order.order_no }}</p>
<h1>{{ order.title }}</h1>
<p v-if="rentalTerms.rentalDays || rentalTerms.dailyLossText" class="order-terms">
<span v-if="rentalTerms.rentalDays" class="term-rental"
>租期 {{ rentalTerms.rentalDays }}天</span
>
<span v-if="rentalTerms.dailyLossText" class="term-loss"
>消耗 {{ rentalTerms.dailyLossText }}</span
>
</p>
<p class="order-meta">{{ order.server_region }} / {{ order.login_platform }}</p>
<el-button size="small" plain :icon="CopyDocument" @click="copyListingCode">
商品编号 {{ listingCode }}
@@ -358,6 +358,31 @@
margin-bottom: 2px;
}
.rental-terms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
}
.rental-terms span {
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 18px;
}
.rental-terms .term-rental {
background: #16a34a;
color: #fff;
}
.rental-terms .term-loss {
background: #f97316;
color: #fff;
}
.rental-title,
.crash-product-info strong {
font-size: 14px;
@@ -5,7 +5,12 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { fetchOrders, hideOwnerOrder, type UserOrderListItem } from '@/features/orders'
import {
fetchOrders,
hideOwnerOrder,
orderListRentalTerms,
type UserOrderListItem,
} from '@/features/orders'
import {
fetchMyMohongOrders,
mohongOrderStatusLabel,
@@ -234,6 +239,17 @@ async function handlePay(order: UserOrderListItem) {
}
}
function rentalTermChips(order?: UserOrderListItem | null) {
const terms = orderListRentalTerms(order)
const chips: Array<{ label: string; value: string; tone: string }> = []
if (terms.rentalDays > 0) {
chips.push({ label: '租期', value: `${terms.rentalDays}天`, tone: 'term-rental' })
}
if (terms.dailyLossText)
chips.push({ label: '消耗', value: terms.dailyLossText, tone: 'term-loss' })
return chips
}
function orderRole(order: UserOrderListItem) {
return order.role === 'owner' ? '号主' : '租客'
}
@@ -427,6 +443,15 @@ function bizTypeLabel(bizType: BizType) {
<el-icon><CopyDocument /></el-icon>
</button>
<span class="rental-title">{{ row.title }}</span>
<p v-if="rentalTermChips(row.rental).length" class="rental-terms">
<span
v-for="chip in rentalTermChips(row.rental)"
:key="chip.label"
:class="chip.tone"
>
{{ chip.label }} {{ chip.value }}
</span>
</p>
</div>
</template>
</el-table-column>
@@ -558,6 +583,15 @@ function bizTypeLabel(bizType: BizType) {
<el-icon><CopyDocument /></el-icon>
</button>
<div class="card-title">{{ item.title }}</div>
<p v-if="rentalTermChips(item.rental).length" class="rental-terms">
<span
v-for="chip in rentalTermChips(item.rental)"
:key="chip.label"
:class="chip.tone"
>
{{ chip.label }} {{ chip.value }}
</span>
</p>
<div class="card-meta">
<div class="meta-row">
<span class="meta-label">{{ amountLabel(item.rental) }}</span>