feat(listings): 折算比按整数展示并统一筛选口径

This commit is contained in:
2026-11-12 14:58:15 +08:00
parent 835a7f8d5b
commit 536f39e347
9 changed files with 106 additions and 24 deletions
@@ -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('')
})
})
+19
View File
@@ -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}>