feat(listings): 仓库物资区分赠送与额外收费并展示小计
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
'未标注'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user