feat(listings): 折算比按整数展示并统一筛选口径
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -63,7 +64,9 @@ func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool {
|
||||
if !numberInRange(coinM, NumberRange{Min: query.MinCoin, Max: query.MaxCoin}) {
|
||||
return false
|
||||
}
|
||||
if !numberInRange(ratioFromListing(item), NumberRange{Min: query.MinRatio, Max: query.MaxRatio}) {
|
||||
// 折算比在前端按四舍五入取整展示,筛选必须用同一口径,
|
||||
// 否则会出现「显示 35w 却不在 35-40 区间」的错位。
|
||||
if !numberInRange(math.Round(ratioFromListing(item)), NumberRange{Min: query.MinRatio, Max: query.MaxRatio}) {
|
||||
return false
|
||||
}
|
||||
if !numberInRange(price, NumberRange{Min: query.MinPrice, Max: query.MaxPrice}) {
|
||||
|
||||
@@ -21,6 +21,31 @@ func TestFilterPublicListingsByRatio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPublicListingsByRoundedRatio(t *testing.T) {
|
||||
minRatio := 30.0
|
||||
maxRatio := 35.0
|
||||
items := []ListingDTO{
|
||||
{ID: 1, AssetSummary: map[string]any{"publish_ratio": 29.4}}, // 29w,区间外
|
||||
{ID: 2, AssetSummary: map[string]any{"publish_ratio": 34.4}}, // 34w,区间内
|
||||
{ID: 3, AssetSummary: map[string]any{"publish_ratio": 34.6}}, // 35w,区间内
|
||||
{ID: 4, AssetSummary: map[string]any{"publish_ratio": 35.4}}, // 35w,区间内(与展示一致)
|
||||
{ID: 5, AssetSummary: map[string]any{"publish_ratio": 35.6}}, // 36w,区间外
|
||||
// 无 publish_ratio 时按「万币 / 元」兜底计算:34.6w → 35w
|
||||
{ID: 6, HafCoinAmount: 34_600_000, PriceCent: 10_000},
|
||||
}
|
||||
|
||||
filtered := filterPublicListings(items, PublicListQuery{MinRatio: &minRatio, MaxRatio: &maxRatio})
|
||||
|
||||
if len(filtered) != 4 {
|
||||
t.Fatalf("expected 4 listings after rounding, got %#v", filtered)
|
||||
}
|
||||
for i, wantID := range []uint64{2, 3, 4, 6} {
|
||||
if filtered[i].ID != wantID {
|
||||
t.Fatalf("at index %d, expected listing %d, got %d", i, wantID, filtered[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPublicListingsRegionMultiSelectRequiresAll(t *testing.T) {
|
||||
items := []ListingDTO{
|
||||
{
|
||||
|
||||
@@ -118,6 +118,12 @@ describe('admin listing display helpers', () => {
|
||||
expect(listingRatioText(item)).toBe('1:43')
|
||||
})
|
||||
|
||||
it('rounds ratio to an integer', () => {
|
||||
expect(listingRatioText(listing({ asset_summary: { publish_ratio: 33.8256 } }))).toBe('1:34')
|
||||
expect(listingRatioText(listing({ asset_summary: { publish_ratio: 33.4 } }))).toBe('1:33')
|
||||
expect(listingRatioText(listing({ asset_summary: {} }))).toBe('—')
|
||||
})
|
||||
|
||||
it('falls back safely when fields are absent', () => {
|
||||
const item = listing({ listing_no: '', description: '', asset_summary: null })
|
||||
expect(listingCodeText(item)).toBe('SP000001')
|
||||
|
||||
@@ -148,7 +148,7 @@ export function listingRentDepositText(row: AdminListing) {
|
||||
export function listingRatioText(row: AdminListing) {
|
||||
const ratio = numberOf(row.asset_summary?.publish_ratio)
|
||||
if (ratio <= 0) return '—'
|
||||
return `1:${compactNumber(ratio)}`
|
||||
return `1:${Math.round(ratio)}`
|
||||
}
|
||||
|
||||
export function listingAccountLevel(row: AdminListing) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
faceOwnerText,
|
||||
gameNameChangeText,
|
||||
listingPriceDetail,
|
||||
listingRatioText,
|
||||
listingRentalDays,
|
||||
} from '@/features/listings/labels'
|
||||
import { recordListingView, useListingCollections } from '@/features/listings/collections'
|
||||
@@ -126,15 +127,6 @@ function resourcesOf(item: Listing) {
|
||||
.filter(row => row.label && row.quantity > 0)
|
||||
}
|
||||
|
||||
function ratioOf(item: Listing) {
|
||||
const published = Number(item.asset_summary?.publish_ratio ?? 0)
|
||||
if (published > 0) return `${published}w`
|
||||
const priceYuan = item.price_cent / 100
|
||||
const coinM = item.haf_coin_amount / 1_000_000
|
||||
if (priceYuan <= 0 || coinM <= 0) return ''
|
||||
return `${((coinM / priceYuan) * 100).toFixed(0)}w`
|
||||
}
|
||||
|
||||
function coreStats(item: Listing) {
|
||||
const summary = item.asset_summary ?? {}
|
||||
const dailyLoss = Number(summary.daily_loss_m ?? 0)
|
||||
@@ -143,10 +135,11 @@ function coreStats(item: Listing) {
|
||||
const faceOwner = faceOwnerText(summary)
|
||||
const banRecord = banRecordText(summary)
|
||||
const gameNameChange = gameNameChangeText(summary)
|
||||
const ratio = listingRatioText(item)
|
||||
return [
|
||||
{ label: '哈夫币', value: formatCoinM(item.haf_coin_amount) },
|
||||
{ label: '保险格数', value: textOf(summary.season_insurance) },
|
||||
{ label: '折算比', value: ratioOf(item) ? `1 元 ≈ ${ratioOf(item)}` : '' },
|
||||
{ label: '折算比', value: ratio ? `1 元 ≈ ${ratio}` : '' },
|
||||
{ label: '火力等级', value: textOf(summary.fire_level) },
|
||||
{ label: '体力', value: textOf(summary.stamina_level) },
|
||||
{ label: '负重', value: textOf(summary.load_level) },
|
||||
@@ -414,7 +407,7 @@ export function ListingDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span>折算比</span>
|
||||
<strong>{ratioOf(data) || '—'}</strong>
|
||||
<strong>{listingRatioText(data) || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>段位</span>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
banRecordText,
|
||||
faceOwnerText,
|
||||
gameNameChangeText,
|
||||
listingRatioText,
|
||||
listingRentalDays,
|
||||
onlineTimeText,
|
||||
} from '@/features/listings/labels'
|
||||
@@ -86,15 +87,6 @@ function assetText(item: Listing, key: string) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function ratioText(item: Listing) {
|
||||
const published = Number(item.asset_summary?.publish_ratio ?? 0)
|
||||
if (published > 0) return `${published}w`
|
||||
const priceYuan = item.price_cent / 100
|
||||
const coinM = item.haf_coin_amount / 1_000_000
|
||||
if (priceYuan <= 0 || coinM <= 0) return ''
|
||||
return `${((coinM / priceYuan) * 100).toFixed(0)}w`
|
||||
}
|
||||
|
||||
function skinNames(item: Listing): string[] {
|
||||
const groups = item.asset_summary?.skin_groups
|
||||
if (!groups || typeof groups !== 'object') return []
|
||||
@@ -154,7 +146,10 @@ function ListingRow({ item }: { item: Listing }) {
|
||||
{ label: '人脸', value: faceOwner },
|
||||
{ label: '封禁', value: banRecord },
|
||||
{ label: '改名', value: gameNameChange },
|
||||
{ label: '折算比', value: ratioText(item) ? `1元≈${ratioText(item)}` : '' },
|
||||
{
|
||||
label: '折算比',
|
||||
value: listingRatioText(item) ? `1元≈${listingRatioText(item)}` : '',
|
||||
},
|
||||
].filter(spec => spec.value)
|
||||
const skins = skinNames(item)
|
||||
const resources = resourceItems(item)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { listingRatioText } from '@/features/listings/labels'
|
||||
|
||||
type RatioItem = {
|
||||
price_cent: number
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function listing(patch: Partial<RatioItem> = {}): RatioItem {
|
||||
return { price_cent: 0, haf_coin_amount: 0, asset_summary: {}, ...patch }
|
||||
}
|
||||
|
||||
describe('listingRatioText', () => {
|
||||
it('优先使用 publish_ratio,并按四舍五入取整到万', () => {
|
||||
expect(listingRatioText(listing({ asset_summary: { publish_ratio: 33.8256 } }))).toBe('34w')
|
||||
expect(listingRatioText(listing({ asset_summary: { publish_ratio: 33.4 } }))).toBe('33w')
|
||||
})
|
||||
|
||||
it('缺少 publish_ratio 时按「万币 / 元」兜底计算', () => {
|
||||
expect(listingRatioText(listing({ haf_coin_amount: 30_000_000, price_cent: 9_369 }))).toBe(
|
||||
'32w'
|
||||
)
|
||||
})
|
||||
|
||||
it('价格或币量缺失时返回空串,不渲染占位', () => {
|
||||
expect(listingRatioText(listing({ haf_coin_amount: 30_000_000 }))).toBe('')
|
||||
expect(listingRatioText(listing({ price_cent: 10_000 }))).toBe('')
|
||||
expect(listingRatioText(listing({ asset_summary: { publish_ratio: 0 } }))).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,25 @@ function formatCentAmount(cent: number) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 折算比文案,单位「万哈夫币 / 元」,统一按四舍五入取整展示。
|
||||
* 后端比例筛选也用同一口径(math.Round),保证展示与筛选一致。
|
||||
*/
|
||||
export function listingRatioText(item: {
|
||||
price_cent: number
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
}): string {
|
||||
const published = Number(item.asset_summary?.publish_ratio ?? 0)
|
||||
if (Number.isFinite(published) && published > 0) return `${Math.round(published)}w`
|
||||
|
||||
const priceYuan = item.price_cent / 100
|
||||
const coinM = item.haf_coin_amount / 1_000_000
|
||||
if (!Number.isFinite(priceYuan) || !Number.isFinite(coinM)) return ''
|
||||
if (priceYuan <= 0 || coinM <= 0) return ''
|
||||
return `${Math.round((coinM / priceYuan) * 100)}w`
|
||||
}
|
||||
|
||||
export function listingRentalDays(item: {
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
faceOwnerText,
|
||||
gameNameChangeText,
|
||||
listingPriceDetail,
|
||||
listingRatioText,
|
||||
listingRentalDays,
|
||||
} from '@/features/listings/labels'
|
||||
import { ensureSupportConversation } from '@/features/messages/api'
|
||||
@@ -157,12 +158,14 @@ export function MobileListingDetailPage() {
|
||||
key !== 'can_change_game_name' &&
|
||||
key !== 'modifiable_name' &&
|
||||
key !== 'pure_coin_price_cent' &&
|
||||
key !== 'extra_item_price_cent'
|
||||
key !== 'extra_item_price_cent' &&
|
||||
key !== 'publish_ratio'
|
||||
)
|
||||
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 priceDetail = listingPriceDetail(data, { compact: true })
|
||||
const isOwner = user?.id === data.owner_id
|
||||
|
||||
@@ -219,6 +222,12 @@ export function MobileListingDetailPage() {
|
||||
<section className="mobile-detail__section">
|
||||
<h2>资产摘要</h2>
|
||||
<dl className="mobile-detail__assets">
|
||||
{ratio ? (
|
||||
<div>
|
||||
<dt>折算比</dt>
|
||||
<dd>1 元 ≈ {ratio}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{assetEntries.length ? (
|
||||
assetEntries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
|
||||
Reference in New Issue
Block a user