diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go
index 7c7a13f..9d5b451 100644
--- a/backend/internal/modules/order/dto.go
+++ b/backend/internal/modules/order/dto.go
@@ -96,6 +96,8 @@ type UserOrderListItemDTO struct {
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
+ RentalDays int `json:"rental_days,omitempty"`
+ DailyLossM float64 `json:"daily_loss_m,omitempty"`
DisplayAmountCent int64 `json:"display_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go
index 6782ef2..b975a1c 100644
--- a/backend/internal/modules/order/presenter.go
+++ b/backend/internal/modules/order/presenter.go
@@ -179,6 +179,7 @@ func (row orderRow) toUserListItem(userID uint64, paymentDeadlineAt *time.Time)
if role == "owner" && row.OwnerRentAmountCent > 0 {
displayAmountCent = row.OwnerRentAmountCent
}
+ rentalDays, dailyLossM := orderListingTerms(row.AccountSnapshot)
return UserOrderListItemDTO{
ID: row.ID,
OrderNo: row.OrderNo,
@@ -188,6 +189,8 @@ func (row orderRow) toUserListItem(userID uint64, paymentDeadlineAt *time.Time)
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
+ RentalDays: rentalDays,
+ DailyLossM: dailyLossM,
DisplayAmountCent: displayAmountCent,
DepositAmountCent: row.DepositAmountCent,
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
diff --git a/backend/internal/modules/order/presenter_test.go b/backend/internal/modules/order/presenter_test.go
index a5a38de..ee0702d 100644
--- a/backend/internal/modules/order/presenter_test.go
+++ b/backend/internal/modules/order/presenter_test.go
@@ -7,6 +7,8 @@ import (
"time"
"hfb_sys/backend/internal/model"
+
+ "gorm.io/datatypes"
)
func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) {
@@ -72,6 +74,44 @@ func TestUserOrderListItemDoesNotSerializeDetailFields(t *testing.T) {
}
}
+func TestUserOrderListItemIncludesRentalTerms(t *testing.T) {
+ row := orderRow{
+ RentalOrder: model.RentalOrder{
+ AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":100000000,"asset_summary":{"daily_loss_m":10}}`)),
+ },
+ Title: "微信 三角洲巅峰 100M哈夫币",
+ }
+
+ item := row.toUserListItem(20, nil)
+ if item.RentalDays != 10 {
+ t.Fatalf("rental days = %d, want 10", item.RentalDays)
+ }
+ if item.DailyLossM != 10 {
+ t.Fatalf("daily loss = %v, want 10", item.DailyLossM)
+ }
+
+ raw, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+ text := string(raw)
+ if !strings.Contains(text, `"rental_days":10`) || !strings.Contains(text, `"daily_loss_m":10`) {
+ t.Fatalf("list response missing rental terms: %s", text)
+ }
+}
+
+func TestUserOrderListItemOmitsRentalTermsWithoutSnapshot(t *testing.T) {
+ item := orderRow{Title: "测试账号"}.toUserListItem(20, nil)
+ raw, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+ text := string(raw)
+ if strings.Contains(text, "rental_days") || strings.Contains(text, "daily_loss_m") {
+ t.Fatalf("empty snapshot should omit rental terms: %s", text)
+ }
+}
+
func TestAdminOrderListItemShowsPhonesAndOmitsSnapshot(t *testing.T) {
row := orderRow{
RentalOrder: model.RentalOrder{ID: 1, OrderNo: "ORD-001", Status: orderStatusPendingPayment},
diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go
index f4ed584..c26dc4f 100644
--- a/backend/internal/modules/order/pricing.go
+++ b/backend/internal/modules/order/pricing.go
@@ -124,6 +124,20 @@ func orderEstimatedEndAt(order model.RentalOrder) *time.Time {
return &endAt
}
+// orderListingTerms 用下单快照里的哈夫币和日损耗估算租期,和首页「可租 / 消耗」同一口径。
+func orderListingTerms(snapshot datatypes.JSON) (days int, dailyLossM float64) {
+ dailyLossM = readOrderSnapshotAssetNumber(snapshot, "daily_loss_m")
+ coinM := readOrderSnapshotCoinM(snapshot)
+ if dailyLossM <= 0 || coinM <= 0 {
+ return 0, 0
+ }
+ days = int(math.Ceil(coinM / dailyLossM))
+ if days < 1 {
+ days = 1
+ }
+ return days, roundQuantity(dailyLossM)
+}
+
func estimateOrderDurationHours(snapshot datatypes.JSON) int {
hafCoinM := readOrderSnapshotCoinM(snapshot)
if hafCoinM <= 0 {
diff --git a/frontend/src/features/listings/views/ListingDetailView.css b/frontend/src/features/listings/views/ListingDetailView.css
index 467806d..601437f 100644
--- a/frontend/src/features/listings/views/ListingDetailView.css
+++ b/frontend/src/features/listings/views/ListingDetailView.css
@@ -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 {
diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue
index 760930a..7155ad5 100644
--- a/frontend/src/features/listings/views/ListingDetailView.vue
+++ b/frontend/src/features/listings/views/ListingDetailView.vue
@@ -489,8 +489,8 @@ function goBack() {
-
-
+
+
哈夫币资产
@@ -513,13 +513,16 @@ function goBack() {
{{ ratioDisplay }}
-
-
租用与损耗
-
- 可租 {{ rentalDuration }}
- 消耗 {{ dailyLoss }}/天
-
+
可租
+
{{ rentalDuration }}
+
+
+
+ 消耗
+ {{
+ dailyLoss ? `${dailyLoss}/天` : '--'
+ }}
diff --git a/frontend/src/features/listings/views/MobileListingDetailView.css b/frontend/src/features/listings/views/MobileListingDetailView.css
index 6d1d172..d97d291 100644
--- a/frontend/src/features/listings/views/MobileListingDetailView.css
+++ b/frontend/src/features/listings/views/MobileListingDetailView.css
@@ -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) ========== */
diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue
index 6e54ff2..c54f096 100644
--- a/frontend/src/features/listings/views/MobileListingDetailView.vue
+++ b/frontend/src/features/listings/views/MobileListingDetailView.vue
@@ -466,8 +466,8 @@ function goBack() {
{{ getListingSubtitle(listing) }}
-
-
+
+
哈夫币资产
@@ -490,13 +490,14 @@ function goBack() {
{{ ratioDisplay }}
-
-
租用与损耗
-
- 可租 {{ rentalDuration }}
- 消耗 {{ dailyLoss }}/天
-
+
可租
+
{{ rentalDuration }}
+
+
+
+ 消耗
+ {{ dailyLoss ? `${dailyLoss}/天` : '--' }}
diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts
index 2ca588b..218e253 100644
--- a/frontend/src/features/orders/api/orders.ts
+++ b/frontend/src/features/orders/api/orders.ts
@@ -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
diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts b/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts
new file mode 100644
index 0000000..5865a7a
--- /dev/null
+++ b/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts
@@ -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/天',
+ })
+ })
+})
diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts
index f10ce26..4e19681 100644
--- a/frontend/src/features/orders/composables/useOrderSnapshot.ts
+++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts
@@ -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
diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.css b/frontend/src/features/orders/views/MobileOrderDetailView.css
index 5869495..47b9eb0 100644
--- a/frontend/src/features/orders/views/MobileOrderDetailView.css
+++ b/frontend/src/features/orders/views/MobileOrderDetailView.css
@@ -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 {
diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue
index 8e3a93f..df28a3c 100644
--- a/frontend/src/features/orders/views/MobileOrderDetailView.vue
+++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue
@@ -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 }}
{{ order.title }}
+
+ 租期 {{ rentalTerms.rentalDays }}天
+ 消耗 {{ rentalTerms.dailyLossText }}
+