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 }} +

{{ orderAmountLabel }} diff --git a/frontend/src/features/orders/views/MobileOrdersView.vue b/frontend/src/features/orders/views/MobileOrdersView.vue index 8b90d3f..c2b088f 100644 --- a/frontend/src/features/orders/views/MobileOrdersView.vue +++ b/frontend/src/features/orders/views/MobileOrdersView.vue @@ -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) }}

{{ item.title }}

+

+ + {{ chip.label }} {{ chip.value }} + +

{{ item.rental.server_region }} {{ item.rental.login_platform }} @@ -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; diff --git a/frontend/src/features/orders/views/OrderDetailView.css b/frontend/src/features/orders/views/OrderDetailView.css index b66e5cf..b9abe5c 100644 --- a/frontend/src/features/orders/views/OrderDetailView.css +++ b/frontend/src/features/orders/views/OrderDetailView.css @@ -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; diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 3f59a34..8ce6c75 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -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], () => {

订单号:{{ order.order_no }}

{{ order.title }}

+

+ 租期 {{ rentalTerms.rentalDays }}天 + 消耗 {{ rentalTerms.dailyLossText }} +

{{ order.server_region }} / {{ order.login_platform }}

商品编号 {{ listingCode }} diff --git a/frontend/src/features/orders/views/OrdersView.css b/frontend/src/features/orders/views/OrdersView.css index 638f47a..95c33f5 100644 --- a/frontend/src/features/orders/views/OrdersView.css +++ b/frontend/src/features/orders/views/OrdersView.css @@ -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; diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index b2a15ac..cf6b82a 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -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) { {{ row.title }} +

+ + {{ chip.label }} {{ chip.value }} + +

@@ -558,6 +583,15 @@ function bizTypeLabel(bizType: BizType) {
{{ item.title }}
+

+ + {{ chip.label }} {{ chip.value }} + +

{{ amountLabel(item.rental) }}