diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index 9a07f8b..3a28afb 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -78,12 +78,28 @@ func applyPublicListingURLs(item *ListingDTO) { rawScreenshotURLs := item.ScreenshotURLS item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus) if item.AssetSummary != nil { + applyPublicPriceBreakdown(item.AssetSummary) delete(item.AssetSummary, "import_meta") delete(item.AssetSummary, "price_breakdown") applyPublicScreenshotGroups(item.ID, item.AssetSummary, rawScreenshotURLs, item.ScreenshotURLS) } } +// applyPublicPriceBreakdown 把买家侧的纯币租金和额外物资摊平到资产摘要顶层。 +// 公共接口随后会删除完整的 price_breakdown,避免暴露卖家成本和平台加价。 +func applyPublicPriceBreakdown(summary map[string]any) { + breakdown, ok := summary["price_breakdown"].(map[string]any) + if !ok { + return + } + if pureCoinCent := summaryMoneyCent(breakdown, "buyer_coin_base_price_cent", "buyer_coin_base_price"); pureCoinCent > 0 { + summary["pure_coin_price_cent"] = pureCoinCent + } + if extraItemCent := summaryMoneyCent(breakdown, "consumable_price_cent", "consumable_price"); extraItemCent > 0 { + summary["extra_item_price_cent"] = extraItemCent + } +} + func applyPublicScreenshotGroups(listingID uint64, assetSummary map[string]any, rawURLs []string, publicURLs []string) { rawGroups, ok := assetSummary["screenshot_groups"].(map[string]any) if !ok { diff --git a/frontend/src/features/listings/ListingDetailPage.tsx b/frontend/src/features/listings/ListingDetailPage.tsx index 93b8633..e3f65d0 100644 --- a/frontend/src/features/listings/ListingDetailPage.tsx +++ b/frontend/src/features/listings/ListingDetailPage.tsx @@ -28,6 +28,7 @@ import { banRecordText, faceOwnerText, gameNameChangeText, + listingPriceDetail, listingRentalDays, } from '@/features/listings/labels' import { recordListingView, useListingCollections } from '@/features/listings/collections' @@ -294,6 +295,7 @@ export function ListingDetailPage() { const isOwner = user?.id === data.owner_id const favorited = token ? Boolean(favoriteIDsQuery.data?.has(data.id)) : isFavorite(data.id) const totalCent = data.price_cent + data.deposit_amount_cent + const priceDetail = listingPriceDetail(data) const toggleFavoriteState = async () => { if (token) { @@ -396,7 +398,10 @@ export function ListingDetailPage() {
总租金 - {money(data.price_cent)} + + {money(data.price_cent)} + {priceDetail ? {priceDetail} : null} + 押金 {money(data.deposit_amount_cent)} · 合计 {money(totalCent)} @@ -589,7 +594,10 @@ export function ListingDetailPage() { {data.title} - {money(data.price_cent)} + + {money(data.price_cent)} + {priceDetail ? {priceDetail} : null} + {money(data.deposit_amount_cent)} {formatCoinM(data.haf_coin_amount)} diff --git a/frontend/src/features/listings/labels.ts b/frontend/src/features/listings/labels.ts index ba13093..76343ec 100644 --- a/frontend/src/features/listings/labels.ts +++ b/frontend/src/features/listings/labels.ts @@ -115,6 +115,46 @@ export function gameNameChangeText(summary?: Record | null): st return text } +export function listingPriceBreakdown(item: { + price_cent: number + asset_summary?: Record +}) { + const summary = item.asset_summary ?? {} + const pureCoinCent = Number(summary.pure_coin_price_cent ?? 0) + const extraItemCent = Number(summary.extra_item_price_cent ?? 0) + return { + pureCoinCent: Number.isFinite(pureCoinCent) ? pureCoinCent : 0, + extraItemCent: Number.isFinite(extraItemCent) ? extraItemCent : 0, + hasBreakdown: pureCoinCent > 0 || extraItemCent > 0, + } +} + +export function listingPriceDetail( + item: { + price_cent: number + asset_summary?: Record + }, + options?: { compact?: boolean } +): string { + const { pureCoinCent, extraItemCent } = listingPriceBreakdown(item) + const pureLabel = options?.compact ? '纯币' : '纯币租金' + const extraLabel = options?.compact ? '物资' : '额外物资' + const parts = [ + pureCoinCent > 0 ? `${pureLabel} ¥${formatCentAmount(pureCoinCent)}` : '', + extraItemCent > 0 ? `${extraLabel} ¥${formatCentAmount(extraItemCent)}` : '', + ].filter(Boolean) + if (!parts.length) return '' + const text = parts.join(' + ') + return options?.compact ? text : `(${text})` +} + +function formatCentAmount(cent: number) { + return (cent / 100).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + export function listingRentalDays(item: { haf_coin_amount: number asset_summary?: Record diff --git a/frontend/src/features/mobile/MobileListingDetailPage.tsx b/frontend/src/features/mobile/MobileListingDetailPage.tsx index cca41d8..92d00ba 100644 --- a/frontend/src/features/mobile/MobileListingDetailPage.tsx +++ b/frontend/src/features/mobile/MobileListingDetailPage.tsx @@ -9,6 +9,7 @@ import { banRecordText, faceOwnerText, gameNameChangeText, + listingPriceDetail, listingRentalDays, } from '@/features/listings/labels' import { ensureSupportConversation } from '@/features/messages/api' @@ -154,12 +155,15 @@ export function MobileListingDetailPage() { key !== 'face_owner' && key !== 'ban_record' && key !== 'can_change_game_name' && - key !== 'modifiable_name' + key !== 'modifiable_name' && + key !== 'pure_coin_price_cent' && + key !== 'extra_item_price_cent' ) const rentalDays = listingRentalDays(data) const faceOwner = faceOwnerText(data.asset_summary) const banRecord = banRecordText(data.asset_summary) const gameNameChange = gameNameChangeText(data.asset_summary) + const priceDetail = listingPriceDetail(data, { compact: true }) const isOwner = user?.id === data.owner_id return ( @@ -193,6 +197,9 @@ export function MobileListingDetailPage() {
租金 {money(data.price_cent)} + {priceDetail ? ( + {priceDetail} + ) : null}
押金 diff --git a/frontend/src/styles/mobile-listing.css b/frontend/src/styles/mobile-listing.css index a663c20..058b920 100644 --- a/frontend/src/styles/mobile-listing.css +++ b/frontend/src/styles/mobile-listing.css @@ -102,6 +102,15 @@ font-size: 18px; } +.mobile-detail__price-detail { + display: block; + margin-top: 3px; + color: var(--ink-muted); + font-size: 9px; + font-weight: 400; + line-height: 1.4; +} + .mobile-detail__section { padding: 20px 0 0; } diff --git a/frontend/src/styles/storefront-listing-detail.css b/frontend/src/styles/storefront-listing-detail.css index 2708512..b2dd298 100644 --- a/frontend/src/styles/storefront-listing-detail.css +++ b/frontend/src/styles/storefront-listing-detail.css @@ -196,6 +196,24 @@ font-style: normal; } +.ld-price__detail { + display: inline-block; + margin-left: 8px; + color: var(--ink-muted); + font-family: inherit; + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + vertical-align: baseline; +} + +.ld-modal-detail { + display: block; + margin-top: 2px; + color: var(--ink-muted); + font-size: 12px; +} + .ld-quick { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));