Files
hfb_sys/frontend/src/shared/utils/listingDisplay.ts
T
yml2213 85332df2bd 订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计
- 用户 token 增加版本控制,冻结/改密/退出即时撤销会话
- 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie
- 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属
- 公开商品接口返回最小字段,隐藏号主身份与内部状态
- 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
2026-08-16 21:47:46 +08:00

400 lines
14 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 { PublicListingItem } 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: PublicListingItem) {
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
}
export function getCoinM(item: PublicListingItem) {
return getCoinWan(item) / 100
}
export function formatListingCode(item: PublicListingItem) {
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: PublicListingItem) {
return centToYuan(item.price_cent)
}
export function getListingRentPrice(item: PublicListingItem) {
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: PublicListingItem) {
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: PublicListingItem) {
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: PublicListingItem) {
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: PublicListingItem) {
const ratio = getRatioValue(item)
return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--'
}
export function getValuePerYuanText(item: PublicListingItem) {
return formatRatio(item)
}
export function getLoginMethod(item: PublicListingItem) {
return item.login_platform.trim()
}
export function getServerRegion(item: PublicListingItem) {
return item.server_region.trim()
}
// 把后端机读游戏名转成中文展示名。
export function formatGameName(value: unknown, fallback = '-') {
const text = String(value || '').trim()
if (!text) return fallback
const normalized = text.toLowerCase().replace(/[\s_-]+/g, '')
if (normalized === 'deltaforce') {
return '三角洲'
}
return text
}
export function getListingTitle(item: PublicListingItem) {
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: PublicListingItem) {
return getValuePerYuanText(item)
}
export function getListingChips(item: PublicListingItem): 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, 'can_change_game_name') === '是') {
chips.push({ label: '可修改游戏名', value: '是' })
}
// 全英雄为必选项,仅在号主勾选「是」时展示
if (readAssetString(item, 'all_hero') === '是') {
chips.push({ label: '全英雄', value: '是' })
}
return chips.filter(chip => chip.value)
}
export function getListingResources(item: PublicListingItem): 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: PublicListingItem, resourceKey: string) {
return getListingResources(item).find(resource => resource.key === resourceKey)?.quantity || 0
}
export function hasGiftResources(item: PublicListingItem) {
return getListingResources(item).some(resource => resource.mode === '赠送')
}
export function hasAcceleratedSaleRatio(item: PublicListingItem) {
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: PublicListingItem, 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: PublicListingItem): 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: '武器',
other: '其他',
imported: '其他',
}
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: PublicListingItem) {
return getListingSkinGroups(item).flatMap(group => group.options)
}
export function assetRegions(item: PublicListingItem) {
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: PublicListingItem) {
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: PublicListingItem) {
const dailyLossM = getDailyLossM(item)
return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : ''
}
export function getDailyLossM(item: PublicListingItem) {
const configuredLoss = readAssetNumber(item, 'daily_loss_m')
if (configuredLoss > 0) return configuredLoss
return 0
}
export function getEstimatedRentalDays(item: PublicListingItem) {
const coinM = getCoinM(item)
const dailyLossM = getDailyLossM(item)
if (coinM <= 0 || dailyLossM <= 0) return 0
return coinM / dailyLossM
}
export function formatEstimatedRentalDuration(item: PublicListingItem) {
const days = getEstimatedRentalDays(item)
if (days <= 0) return '--'
// 不足整天按整天向上取整,与后端 estimateOrderDurationHours 一致
return `${Math.max(1, Math.ceil(days))}天`
}
export function readAssetString(item: PublicListingItem, key: string) {
const value = item.asset_summary?.[key]
return typeof value === 'string' ? value : ''
}
export function readAssetNumber(item: PublicListingItem, 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: PublicListingItem, 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: PublicListingItem, 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)
}