Files
hfb_sys/frontend/src/shared/utils/listingDisplay.ts
T
yml2213 ae79550d78 支持在线时间跨天并优化发布与筛选体验
允许在线时段 end 早于 start 表示跨天,统一展示「次日」文案;修复移动端价格区间清空后被 sessionStorage 回写;优化 PC/移动端在线时间控件密度与通栏展示。
2026-07-19 17:28:57 +08:00

382 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Listing } from '@/features/listings/api/listings'
import { centToYuan } from '@/shared/utils/money'
export interface ListingDisplayChip {
label: string
value: string
}
export interface ListingDisplayResource {
key: string
label: string
price: string
quantity: number
mode: string
amount: number
}
export interface ListingDisplaySkinGroup {
key: string
title: string
options: string[]
}
export function getCoinWan(item: Listing) {
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
}
export function getCoinM(item: Listing) {
return getCoinWan(item) / 100
}
export function formatListingCode(item: Listing) {
return formatListingNo(item.listing_no, item.id)
}
export function formatListingNo(listingNo?: string, listingID?: number | string) {
if (listingNo) return listingNo
if (listingID !== undefined && listingID !== null && listingID !== '') {
return `SP${String(listingID).padStart(6, '0')}`
}
return '-'
}
export function formatHafCoinM(amountWan: number) {
const amountM = amountWan / 100
const rounded = Math.round(amountM * 10) / 10
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`
}
export function formatAssetNumber(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
export function getListingDisplayPrice(item: Listing) {
return centToYuan(item.price_cent)
}
export function getListingRentPrice(item: Listing) {
const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price')
if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice)
return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item)))
}
export function getListingConsumablePrice(item: Listing) {
const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price')
if (consumablePrice > 0) return roundMoney(consumablePrice)
return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0)
}
export function getListingSellerPrice(item: Listing) {
const priceBreakdown = item.asset_summary?.price_breakdown
if (typeof priceBreakdown === 'object' && priceBreakdown !== null) {
const price = readUnknownNumber((priceBreakdown as Record<string, unknown>).seller_total_price)
if (price > 0) return price
}
return getListingDisplayPrice(item)
}
export function getRatioValue(item: Listing) {
const ratio = readAssetNumber(item, 'publish_ratio')
if (ratio > 0) return ratio
const price = getListingDisplayPrice(item)
if (price <= 0) return 0
return getCoinWan(item) / price
}
export function formatRatio(item: Listing) {
const ratio = getRatioValue(item)
return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--'
}
export function getValuePerYuanText(item: Listing) {
return formatRatio(item)
}
export function getLoginMethod(item: Listing) {
return item.login_platform.trim()
}
export function getServerRegion(item: Listing) {
return item.server_region.trim()
}
export function getListingTitle(item: Listing) {
const parts = [
`纯币${formatHafCoinM(getCoinWan(item))}`,
formatInsuranceSlotText(readAssetString(item, 'season_insurance')),
formatLevelShort(readAssetString(item, 'stamina_level'), '体'),
formatLevelShort(readAssetString(item, 'load_level'), '负'),
formatResourceShort(item, 'armor6', '六甲'),
formatResourceShort(item, 'helmet6', '六头'),
...getSkinNames(item).slice(0, 4),
].filter(Boolean)
return parts.join('/')
}
export function getListingSubtitle(item: Listing) {
return getValuePerYuanText(item)
}
export function getListingChips(item: Listing): ListingDisplayChip[] {
const totalAsset = readAssetNumber(item, 'total_asset_wan')
const chips: ListingDisplayChip[] = [
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(item)) },
{ label: '保险格数', value: readAssetString(item, 'season_insurance') },
{ label: '体力', value: readAssetString(item, 'stamina_level') },
{ label: '负重', value: readAssetString(item, 'load_level') },
{ label: '段位', value: item.rank_level },
]
const awmAmmo = getResourceQuantity(item, 'awmAmmo')
if (awmAmmo > 0) {
chips.push({ label: 'AWM', value: `${awmAmmo}发` })
}
if (totalAsset > 0) {
chips.push({ label: '总资产', value: formatHafCoinM(totalAsset) })
}
const online = getOnlineTimeText(item)
if (online) {
chips.push({ label: '方便上号', value: online })
}
// 解锁赛伊德为必选项,仅在号主勾选「是」时展示
if (readAssetString(item, 'unlock_said') === '是') {
chips.push({ label: '解锁赛伊德', value: '是' })
}
return chips.filter(chip => chip.value)
}
export function getListingResources(item: Listing): ListingDisplayResource[] {
const resources = item.asset_summary?.resources
if (!Array.isArray(resources)) return []
return resources
.map(resource => {
if (typeof resource !== 'object' || resource === null) return null
const row = resource as Record<string, unknown>
return {
key: typeof row.key === 'string' ? row.key : '',
label: typeof row.label === 'string' ? row.label : '',
price: typeof row.price === 'string' ? row.price : '',
quantity: readUnknownNumber(row.quantity),
mode: typeof row.mode === 'string' ? row.mode : '',
amount:
row.mode === '收费'
? roundMoney(
readUnknownNumber(row.quantity) *
readUnitPrice(typeof row.price === 'string' ? row.price : '')
)
: 0,
}
})
.filter((resource): resource is ListingDisplayResource => {
return Boolean(resource?.key && resource.label && resource.quantity > 0)
})
}
export function getResourceQuantity(item: Listing, resourceKey: string) {
return getListingResources(item).find(resource => resource.key === resourceKey)?.quantity || 0
}
export function hasGiftResources(item: Listing) {
return getListingResources(item).some(resource => resource.mode === '赠送')
}
export function hasAcceleratedSaleRatio(item: Listing) {
if (item.is_accelerated_sale) return true
const priceBreakdown = item.asset_summary?.price_breakdown
if (typeof priceBreakdown !== 'object' || priceBreakdown === null) return false
const row = priceBreakdown as Record<string, unknown>
const referenceRatio = readUnknownNumber(row.seller_reference_ratio)
const sellerRatio = readUnknownNumber(row.seller_ratio)
const acceleratedRatio = readUnknownNumber(row.accelerated_sale_ratio)
if (referenceRatio <= 0) return false
return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio
}
export function getSkinGroup(item: Listing, groupKey: string) {
const skinGroups = item.asset_summary?.skin_groups
if (
typeof skinGroups !== 'object' ||
skinGroups === null ||
!Array.isArray((skinGroups as Record<string, unknown>)[groupKey])
) {
return []
}
return ((skinGroups as Record<string, unknown>)[groupKey] as unknown[]).filter(
(skin): skin is string => typeof skin === 'string'
)
}
export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] {
const skinGroups = item.asset_summary?.skin_groups
if (typeof skinGroups !== 'object' || skinGroups === null) return []
const titles: Record<string, string> = {
melee: '近战',
operator: '干员',
operatorGold: '金皮',
operatorRed: '红皮',
weapon: '武器',
}
return Object.entries(skinGroups as Record<string, unknown>)
.map(([key, value]) => ({
key,
title: titles[key] || key,
options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string' && Boolean(skin.trim()))
: [],
}))
.filter(group => group.options.length)
}
export function getSkinNames(item: Listing) {
return getListingSkinGroups(item).flatMap(group => group.options)
}
export function assetRegions(item: Listing) {
const regions = item.asset_summary?.common_regions
return Array.isArray(regions)
? regions.filter((region): region is string => typeof region === 'string')
: []
}
/** 格式化在线时段;end < start 视为跨天,展示「次日」。 */
export function formatOnlineTimeRange(start: string, end: string) {
const startText = String(start || '').trim()
const endText = String(end || '').trim()
if (!startText || !endText) return ''
if (
startText === '全天' ||
endText === '全天' ||
(startText === '00:00' && endText === '23:59')
) {
return '全天'
}
const startMinute = parseClockToMinute(startText)
const endMinute = parseClockToMinute(endText)
const startLabel = formatClockLabel(startText)
const endLabel = formatClockLabel(endText)
if (startMinute !== null && endMinute !== null && endMinute < startMinute) {
return `${startLabel}-次日${endLabel}点`
}
return `${startLabel}-${endLabel}点`
}
export function getOnlineTimeText(item: Listing) {
const onlineTime = item.asset_summary?.online_time
if (typeof onlineTime !== 'object' || onlineTime === null) return ''
const start = (onlineTime as Record<string, unknown>).start
const end = (onlineTime as Record<string, unknown>).end
if (typeof start !== 'string' || typeof end !== 'string' || !start || !end) return ''
return formatOnlineTimeRange(start, end)
}
function parseClockToMinute(value: string) {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim())
if (!match) return null
const hour = Number(match[1])
const minute = Number(match[2])
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
return hour * 60 + minute
}
function formatClockLabel(value: string) {
return value.endsWith(':00') ? value.slice(0, -3) : value
}
export function getDailyLoss(item: Listing) {
const dailyLossM = getDailyLossM(item)
return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : ''
}
export function getDailyLossM(item: Listing) {
const configuredLoss = readAssetNumber(item, 'daily_loss_m')
if (configuredLoss > 0) return configuredLoss
return 0
}
export function getEstimatedRentalDays(item: Listing) {
const coinM = getCoinM(item)
const dailyLossM = getDailyLossM(item)
if (coinM <= 0 || dailyLossM <= 0) return 0
return coinM / dailyLossM
}
export function formatEstimatedRentalDuration(item: Listing) {
const days = getEstimatedRentalDays(item)
if (days <= 0) return '--'
return `${Math.max(1, Math.round(days))}天`
}
export function readAssetString(item: Listing, key: string) {
const value = item.asset_summary?.[key]
return typeof value === 'string' ? value : ''
}
export function readAssetNumber(item: Listing, key: string) {
return readUnknownNumber(item.asset_summary?.[key])
}
function readUnknownNumber(value: unknown) {
if (typeof value === 'number') return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function readPriceBreakdownNumber(item: Listing, key: string) {
const priceBreakdown = item.asset_summary?.price_breakdown
if (typeof priceBreakdown !== 'object' || priceBreakdown === null) return 0
return readUnknownNumber((priceBreakdown as Record<string, unknown>)[key])
}
function readUnitPrice(priceText: string) {
const normalized = priceText.replace(/,/g, ',').trim()
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/)
if (fractionMatch) {
const amount = Number(fractionMatch[1])
const count = Number(fractionMatch[2])
return count > 0 ? amount / count : 0
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/)
return singleMatch ? Number(singleMatch[1]) : 0
}
function formatInsuranceSlotText(value: string) {
const parts = value.split('*').map(item => Number(item))
const rows = parts[0] || 0
const cols = parts[1] || 0
if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) {
return value
}
return `${rows * cols}格`
}
function formatLevelShort(value: string, suffix: string) {
const level = value.match(/\d+/)?.[0]
return level ? `${level}${suffix}` : value
}
function formatResourceShort(item: Listing, key: string, label: string) {
const quantity = getResourceQuantity(item, key)
return quantity > 0 ? `${quantity}${label}` : ''
}
function roundMoney(value: number) {
return Math.round(value * 100) / 100
}
function formatRatioNumber(value: number) {
const rounded = roundMoney(value)
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2)
}
function formatCompactNumber(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}