diff --git a/frontend/src/features/listings/ListingDetailPage.tsx b/frontend/src/features/listings/ListingDetailPage.tsx index b598b56..78cd61d 100644 --- a/frontend/src/features/listings/ListingDetailPage.tsx +++ b/frontend/src/features/listings/ListingDetailPage.tsx @@ -31,6 +31,8 @@ import { listingPriceDetail, listingRatioText, listingRentalDays, + listingResources, + resourceBadgeText, } from '@/features/listings/labels' import { recordListingView, useListingCollections } from '@/features/listings/collections' import { @@ -112,21 +114,6 @@ function skinGroupsOf(item: Listing) { .filter(group => group.names.length > 0) } -function resourcesOf(item: Listing) { - const raw = item.asset_summary?.resources - if (!Array.isArray(raw)) return [] - return raw - .map(entry => { - const row = (entry ?? {}) as Record - return { - label: textOf(row.label) || textOf(row.key), - quantity: Number(row.quantity ?? 0), - mode: textOf(row.mode), - } - }) - .filter(row => row.label && row.quantity > 0) -} - function coreStats(item: Listing) { const summary = item.asset_summary ?? {} const dailyLoss = Number(summary.daily_loss_m ?? 0) @@ -283,7 +270,7 @@ export function ListingDetailPage() { const labels = screenshotLabels(data) const activeLabel = labels.get(activeImage) ?? '' const skins = skinGroupsOf(data) - const resources = resourcesOf(data) + const resources = listingResources(data.asset_summary) const stats = coreStats(data) const isOwner = user?.id === data.owner_id const favorited = token ? Boolean(favoriteIDsQuery.data?.has(data.id)) : isFavorite(data.id) @@ -508,7 +495,7 @@ export function ListingDetailPage() {
{row.label} {row.quantity} - {row.mode === '赠送' ? 赠送 : null} + {resourceBadgeText(row)}
))} diff --git a/frontend/src/features/listings/ListingListPage.tsx b/frontend/src/features/listings/ListingListPage.tsx index a108db8..76620d9 100644 --- a/frontend/src/features/listings/ListingListPage.tsx +++ b/frontend/src/features/listings/ListingListPage.tsx @@ -33,7 +33,9 @@ import { gameNameChangeText, listingRatioText, listingRentalDays, + listingResources, onlineTimeText, + resourceBadgeText, } from '@/features/listings/labels' import { awmRanges, @@ -100,18 +102,6 @@ function skinNames(item: Listing): string[] { return Array.from(new Set(names)).slice(0, 8) } -function resourceItems(item: Listing): { label: string; quantity: number }[] { - const raw = item.asset_summary?.resources - if (!Array.isArray(raw)) return [] - return raw - .map(entry => { - const row = (entry ?? {}) as Record - return { label: String(row.label ?? row.key ?? ''), quantity: Number(row.quantity ?? 0) } - }) - .filter(row => row.label && row.quantity > 0) - .slice(0, 6) -} - function ListingCover({ item }: { item: Listing }) { if (item.cover_url) { return ( @@ -152,7 +142,7 @@ function ListingRow({ item }: { item: Listing }) { }, ].filter(spec => spec.value) const skins = skinNames(item) - const resources = resourceItems(item) + const resources = listingResources(item.asset_summary).slice(0, 6) return (
@@ -201,8 +191,9 @@ function ListingRow({ item }: { item: Listing }) {
仓库物资 {resources.map(row => ( - + {row.label} {row.quantity} + {resourceBadgeText(row, 'compact')} ))}
diff --git a/frontend/src/features/listings/labels.test.ts b/frontend/src/features/listings/labels.test.ts index 09255c7..90db8e2 100644 --- a/frontend/src/features/listings/labels.test.ts +++ b/frontend/src/features/listings/labels.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { listingRatioText } from '@/features/listings/labels' +import { + listingPriceDetail, + listingRatioText, + listingResources, + resourceBadgeText, + resourceChargeText, +} from '@/features/listings/labels' type RatioItem = { price_cent: number @@ -30,3 +36,116 @@ describe('listingRatioText', () => { expect(listingRatioText(listing({ asset_summary: { publish_ratio: 0 } }))).toBe('') }) }) + +describe('listingPriceDetail', () => { + const item = { + price_cent: 191350, + asset_summary: { pure_coin_price_cent: 176350, extra_item_price_cent: 15000 }, + } + + it('把额外物资标成收费项', () => { + expect(listingPriceDetail(item)).toBe('(纯币租金 ¥1,763.50 + 额外物资收费 ¥150.00)') + expect(listingPriceDetail(item, { compact: true })).toBe('纯币 ¥1,763.50 + 物资收费 ¥150.00') + }) + + it('只有纯币租金时省略收费项,无拆分时不渲染', () => { + expect( + listingPriceDetail({ + price_cent: 176350, + asset_summary: { pure_coin_price_cent: 176350, extra_item_price_cent: 0 }, + }) + ).toBe('(纯币租金 ¥1,763.50)') + expect(listingPriceDetail({ price_cent: 176350, asset_summary: {} })).toBe('') + }) +}) + +describe('resourceChargeText', () => { + it('区分赠送、收费与未标注', () => { + expect(resourceChargeText('赠送')).toBe('赠送') + expect(resourceChargeText('收费')).toBe('额外收费') + expect(resourceChargeText('收费', true)).toBe('收费') + expect(resourceChargeText('付费')).toBe('未标注') + expect(resourceChargeText('')).toBe('未标注') + expect(resourceChargeText(undefined)).toBe('未标注') + }) +}) + +describe('listingResources', () => { + it('归一化模式、计算收费项小计并过滤空项', () => { + const rows = listingResources({ + resources: [ + { key: 'ammo6', label: '6级弹', quantity: 50, mode: '收费', price_cent: 10 }, + { key: 'helmet6', label: '6头', quantity: 5, mode: '收费', price: '3.9元/个' }, + { key: 'med', label: '医疗包', quantity: 3, mode: '赠送', price_cent: 100 }, + { key: 'legacy', label: '旧数据', quantity: 2, mode: '付费', price: '2.5元/个' }, + { key: 'empty', label: '', quantity: 1, mode: '收费' }, + { key: 'zero', label: '零数量', quantity: 0, mode: '收费' }, + ], + }) + + expect(rows).toEqual([ + { + label: '6级弹', + quantity: 50, + mode: 'paid', + modeText: '额外收费', + amountCent: 500, + amountText: '5.0', + }, + { + label: '6头', + quantity: 5, + mode: 'paid', + modeText: '额外收费', + amountCent: 1950, + amountText: '19.5', + }, + { + label: '医疗包', + quantity: 3, + mode: 'gift', + modeText: '赠送', + amountCent: 0, + amountText: '', + }, + { + label: '旧数据', + quantity: 2, + mode: 'unknown', + modeText: '未标注', + amountCent: 0, + amountText: '', + }, + ]) + }) + + it('缺少 resources 时返回空数组', () => { + expect(listingResources(undefined)).toEqual([]) + expect(listingResources({})).toEqual([]) + }) +}) + +describe('resourceBadgeText', () => { + const base = { label: '6级弹', quantity: 50, modeText: '额外收费' } as const + + it('收费项带小计金额,赠送与未标注用固定文案', () => { + expect(resourceBadgeText({ ...base, mode: 'paid', amountCent: 500, amountText: '5.0' })).toBe( + '额外收费 ¥5.0' + ) + expect( + resourceBadgeText({ ...base, mode: 'paid', amountCent: 500, amountText: '5.0' }, 'compact') + ).toBe('收费 ¥5.0') + expect(resourceBadgeText({ ...base, mode: 'paid', amountCent: 0, amountText: '' })).toBe( + '额外收费' + ) + expect(resourceBadgeText({ ...base, mode: 'gift', amountCent: 0, amountText: '' })).toBe( + '随号赠送' + ) + expect( + resourceBadgeText({ ...base, mode: 'gift', amountCent: 0, amountText: '' }, 'compact') + ).toBe('赠送') + expect(resourceBadgeText({ ...base, mode: 'unknown', amountCent: 0, amountText: '' })).toBe( + '未标注' + ) + }) +}) diff --git a/frontend/src/features/listings/labels.ts b/frontend/src/features/listings/labels.ts index b2ddd43..9485d7c 100644 --- a/frontend/src/features/listings/labels.ts +++ b/frontend/src/features/listings/labels.ts @@ -1,4 +1,5 @@ import type { StatusMeta } from '@/features/admin/labels' +import { parseUnitPriceCent } from '@/shared/utils/pricing' export const listingStatusMap: Record = { draft: { label: '草稿', tone: 'default' }, @@ -138,7 +139,7 @@ export function listingPriceDetail( ): string { const { pureCoinCent, extraItemCent } = listingPriceBreakdown(item) const pureLabel = options?.compact ? '纯币' : '纯币租金' - const extraLabel = options?.compact ? '物资' : '额外物资' + const extraLabel = options?.compact ? '物资收费' : '额外物资收费' const parts = [ pureCoinCent > 0 ? `${pureLabel} ¥${formatCentAmount(pureCoinCent)}` : '', extraItemCent > 0 ? `${extraLabel} ¥${formatCentAmount(extraItemCent)}` : '', @@ -174,6 +175,80 @@ export function listingRatioText(item: { return `${Math.round((coinM / priceYuan) * 100)}w` } +export type ResourceChargeMode = 'gift' | 'paid' | 'unknown' + +export type ListingResourceRow = { + label: string + quantity: number + mode: ResourceChargeMode + modeText: string + /** 收费项的单项小计(分);赠送或缺少单价时为 0。 */ + amountCent: number + /** 小计金额文案,保留一位小数,例如 18.1。 */ + amountText: string +} + +/** + * 资源计费模式。后端计价只把「收费」计入额外物资, + * 「付费」等历史取值一律视为未标注,避免展示与计价口径不一致。 + */ +export function resourceChargeMode(mode: unknown): ResourceChargeMode { + const text = typeof mode === 'string' ? mode.trim() : '' + if (text === '赠送') return 'gift' + if (text === '收费') return 'paid' + return 'unknown' +} + +export function resourceChargeText(mode: unknown, compact = false): string { + const kind = resourceChargeMode(mode) + if (kind === 'gift') return '赠送' + if (kind === 'paid') return compact ? '收费' : '额外收费' + return '未标注' +} + +export function listingResources(summary?: Record | null): ListingResourceRow[] { + const raw = summary?.resources + if (!Array.isArray(raw)) return [] + return raw + .map(entry => { + const row = (entry ?? {}) as Record + const label = String(row.label ?? row.key ?? '').trim() + const parsedQuantity = Number(row.quantity ?? 0) + const quantity = + Number.isFinite(parsedQuantity) && parsedQuantity > 0 ? Math.trunc(parsedQuantity) : 0 + const mode = resourceChargeMode(row.mode) + const amountCent = mode === 'paid' ? quantity * resourceUnitPriceCent(row) : 0 + return { + label, + quantity, + mode, + modeText: resourceChargeText(row.mode), + amountCent, + amountText: amountCent > 0 ? (amountCent / 100).toFixed(1) : '', + } + }) + .filter(row => row.label && row.quantity > 0) +} + +function resourceUnitPriceCent(row: Record): number { + const priceCent = Number(row.price_cent ?? 0) + if (Number.isFinite(priceCent) && priceCent > 0) return Math.trunc(priceCent) + return parseUnitPriceCent(String(row.price ?? '')) +} + +/** 卡片徽标:收费项带小计金额,赠送项固定文案。 */ +export function resourceBadgeText( + row: ListingResourceRow, + variant: 'detail' | 'compact' = 'detail' +): string { + if (row.mode === 'gift') return variant === 'detail' ? '随号赠送' : '赠送' + if (row.mode === 'paid') { + const label = variant === 'detail' ? '额外收费' : '收费' + return row.amountText ? `${label} ¥${row.amountText}` : label + } + return '未标注' +} + 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 98c9c03..da821ba 100644 --- a/frontend/src/features/mobile/MobileListingDetailPage.tsx +++ b/frontend/src/features/mobile/MobileListingDetailPage.tsx @@ -12,6 +12,8 @@ import { listingPriceDetail, listingRatioText, listingRentalDays, + listingResources, + resourceBadgeText, } from '@/features/listings/labels' import { ensureSupportConversation } from '@/features/messages/api' import { createOrder, useOrderAgreements, usePostRentalNotice } from '@/features/orders/api' @@ -159,13 +161,15 @@ export function MobileListingDetailPage() { key !== 'modifiable_name' && key !== 'pure_coin_price_cent' && key !== 'extra_item_price_cent' && - key !== 'publish_ratio' + key !== 'publish_ratio' && + key !== 'resources' ) const rentalDays = listingRentalDays(data) const faceOwner = faceOwnerText(data.asset_summary) const banRecord = banRecordText(data.asset_summary) const gameNameChange = gameNameChangeText(data.asset_summary) const ratio = listingRatioText(data) + const resources = listingResources(data.asset_summary) const priceDetail = listingPriceDetail(data, { compact: true }) const isOwner = user?.id === data.owner_id @@ -272,6 +276,25 @@ export function MobileListingDetailPage() { + {resources.length ? ( +
+

仓库物资

+
+ {resources.map(row => ( +
+
{row.label}
+
+ {row.quantity} 个 + + {resourceBadgeText(row)} + +
+
+ ))} +
+
+ ) : null} +
先看清规则,再决定要不要租

押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。

diff --git a/frontend/src/styles/mobile-listing.css b/frontend/src/styles/mobile-listing.css index 058b920..c408205 100644 --- a/frontend/src/styles/mobile-listing.css +++ b/frontend/src/styles/mobile-listing.css @@ -157,6 +157,27 @@ overflow-wrap: anywhere; } +.mobile-detail__resource-mode { + display: inline-block; + margin-left: 6px; + padding: 0 6px; + border-radius: 999px; + background: #e8f7ee; + color: #1f9d55; + font-size: 11px; + line-height: 18px; +} + +.mobile-detail__resource-mode.is-paid { + background: #fff0e6; + color: #e8590c; +} + +.mobile-detail__resource-mode.is-unknown { + background: #f1f0e9; + color: var(--ink-muted); +} + .mobile-detail__notice { margin-top: 22px; padding: 16px; diff --git a/frontend/src/styles/storefront-filters.css b/frontend/src/styles/storefront-filters.css index 9b46cd9..b152224 100644 --- a/frontend/src/styles/storefront-filters.css +++ b/frontend/src/styles/storefront-filters.css @@ -295,6 +295,26 @@ color: var(--ink-secondary); } +.listing-row__resources span i { + margin-left: 4px; + padding: 0 4px; + border-radius: 4px; + background: rgba(47, 125, 92, 0.12); + color: var(--success); + font-size: 10px; + font-style: normal; +} + +.listing-row__resources span.is-paid i { + background: rgba(180, 83, 9, 0.12); + color: #b45309; +} + +.listing-row__resources span.is-unknown i { + background: rgba(0, 0, 0, 0.06); + color: var(--ink-muted); +} + .listing-row__side { display: flex; flex-direction: column; diff --git a/frontend/src/styles/storefront-listing-detail.css b/frontend/src/styles/storefront-listing-detail.css index b2dd298..50eb764 100644 --- a/frontend/src/styles/storefront-listing-detail.css +++ b/frontend/src/styles/storefront-listing-detail.css @@ -198,10 +198,10 @@ .ld-price__detail { display: inline-block; - margin-left: 8px; + margin-left: 6px; color: var(--ink-muted); font-family: inherit; - font-size: 12px; + font-size: 11px; font-weight: 400; letter-spacing: 0; vertical-align: baseline; @@ -399,13 +399,23 @@ .ld-resource em { padding: 0 6px; border-radius: 999px; - background: rgba(47, 125, 92, 0.12); - color: var(--success); + background: #e8f7ee; + color: #1f9d55; font-size: 10px; font-style: normal; line-height: 16px; } +.ld-resource em.is-paid { + background: #fff0e6; + color: #e8590c; +} + +.ld-resource em.is-unknown { + background: #f1f0e9; + color: var(--ink-muted); +} + .ld-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);