feat(listings): 仓库物资区分赠送与额外收费并展示小计

This commit is contained in:
2026-11-12 15:43:07 +08:00
parent 536f39e347
commit 927e6a9978
8 changed files with 284 additions and 38 deletions
@@ -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<string, unknown>
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() {
<div className="ld-resource" key={row.label}>
<span>{row.label}</span>
<strong>{row.quantity}</strong>
{row.mode === '赠送' ? <em>赠送</em> : null}
<em className={`ld-resource__mode is-${row.mode}`}>{resourceBadgeText(row)}</em>
</div>
))}
</div>
@@ -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<string, unknown>
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 (
<article className="listing-row">
@@ -201,8 +191,9 @@ function ListingRow({ item }: { item: Listing }) {
<div className="listing-row__resources">
<em>仓库物资</em>
{resources.map(row => (
<span key={row.label}>
<span key={row.label} className={`is-${row.mode}`}>
{row.label} {row.quantity}
<i>{resourceBadgeText(row, 'compact')}</i>
</span>
))}
</div>
+120 -1
View File
@@ -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(
'未标注'
)
})
})
+76 -1
View File
@@ -1,4 +1,5 @@
import type { StatusMeta } from '@/features/admin/labels'
import { parseUnitPriceCent } from '@/shared/utils/pricing'
export const listingStatusMap: Record<string, StatusMeta> = {
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<string, unknown> | null): ListingResourceRow[] {
const raw = summary?.resources
if (!Array.isArray(raw)) return []
return raw
.map(entry => {
const row = (entry ?? {}) as Record<string, unknown>
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<string, unknown>): 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<string, unknown>
@@ -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() {
</dl>
</section>
{resources.length ? (
<section className="mobile-detail__section">
<h2>仓库物资</h2>
<dl className="mobile-detail__assets">
{resources.map(row => (
<div key={row.label}>
<dt>{row.label}</dt>
<dd>
{row.quantity} 个
<span className={`mobile-detail__resource-mode is-${row.mode}`}>
{resourceBadgeText(row)}
</span>
</dd>
</div>
))}
</dl>
</section>
) : null}
<section className="mobile-detail__notice">
<strong>先看清规则,再决定要不要租</strong>
<p>押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。</p>
+21
View File
@@ -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;
@@ -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;
@@ -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);