增加前端格式检查配置
This commit is contained in:
@@ -37,13 +37,14 @@ function logRequest(config: InternalAxiosRequestConfig) {
|
||||
|
||||
function logResponse(response: AxiosResponse) {
|
||||
if (import.meta.env.DEV) {
|
||||
const duration = response.config._startTime
|
||||
? Date.now() - response.config._startTime
|
||||
: 0
|
||||
console.log(`[API Response] ${response.config.method?.toUpperCase()} ${response.config.url} (${duration}ms)`, {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
})
|
||||
const duration = response.config._startTime ? Date.now() - response.config._startTime : 0
|
||||
console.log(
|
||||
`[API Response] ${response.config.method?.toUpperCase()} ${response.config.url} (${duration}ms)`,
|
||||
{
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 记录性能指标
|
||||
@@ -61,14 +62,15 @@ function logResponse(response: AxiosResponse) {
|
||||
|
||||
function logError(error: AxiosError) {
|
||||
if (import.meta.env.DEV) {
|
||||
const duration = error.config?._startTime
|
||||
? Date.now() - error.config._startTime
|
||||
: 0
|
||||
console.error(`[API Error] ${error.config?.method?.toUpperCase()} ${error.config?.url} (${duration}ms)`, {
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
data: error.response?.data,
|
||||
})
|
||||
const duration = error.config?._startTime ? Date.now() - error.config._startTime : 0
|
||||
console.error(
|
||||
`[API Error] ${error.config?.method?.toUpperCase()} ${error.config?.url} (${duration}ms)`,
|
||||
{
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
data: error.response?.data,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 记录错误指标
|
||||
@@ -181,29 +183,32 @@ function isRefreshRequest(url = '') {
|
||||
return url.endsWith('/auth/refresh') || url.endsWith('/admin/auth/refresh')
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const scope = getRequestScope(config.url || '')
|
||||
const token = getAccessToken(scope)
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
apiClient.interceptors.request.use(
|
||||
config => {
|
||||
const scope = getRequestScope(config.url || '')
|
||||
const token = getAccessToken(scope)
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 记录请求日志
|
||||
logRequest(config)
|
||||
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
logError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 记录请求日志
|
||||
logRequest(config)
|
||||
|
||||
return config
|
||||
}, (error) => {
|
||||
logError(error)
|
||||
return Promise.reject(error)
|
||||
})
|
||||
)
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
response => {
|
||||
// 记录响应日志
|
||||
logResponse(response)
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
async error => {
|
||||
// 记录错误日志
|
||||
logError(error)
|
||||
|
||||
@@ -231,7 +236,7 @@ apiClient.interceptors.response.use(
|
||||
if (state.refreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
state.pendingRequests.push({ resolve, reject })
|
||||
}).then((newToken) => {
|
||||
}).then(newToken => {
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
@@ -255,5 +260,5 @@ apiClient.interceptors.response.use(
|
||||
} finally {
|
||||
state.refreshing = false
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -36,11 +36,7 @@ export async function post<T>(
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
export async function put<T>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
export async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return request<T>({ ...config, method: 'PUT', url, data })
|
||||
}
|
||||
|
||||
@@ -91,7 +87,7 @@ export async function requestWithRetry<T>(
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay * (attempt + 1)))
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +106,7 @@ export async function concurrent<T extends readonly unknown[]>(
|
||||
/**
|
||||
* 串行请求 (按顺序执行)
|
||||
*/
|
||||
export async function sequential<T>(
|
||||
requestFns: Array<() => Promise<T>>
|
||||
): Promise<T[]> {
|
||||
export async function sequential<T>(requestFns: Array<() => Promise<T>>): Promise<T[]> {
|
||||
const results: T[] = []
|
||||
|
||||
for (const requestFn of requestFns) {
|
||||
|
||||
@@ -54,7 +54,7 @@ class ApiMonitor {
|
||||
*/
|
||||
getSuccessRate(): number {
|
||||
if (this.metrics.length === 0) return 100
|
||||
const successCount = this.metrics.filter((m) => m.success).length
|
||||
const successCount = this.metrics.filter(m => m.success).length
|
||||
return Math.round((successCount / this.metrics.length) * 100)
|
||||
}
|
||||
|
||||
@@ -62,9 +62,7 @@ class ApiMonitor {
|
||||
* 获取最慢的请求
|
||||
*/
|
||||
getSlowestRequests(count = 5): RequestMetrics[] {
|
||||
return [...this.metrics]
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
.slice(0, count)
|
||||
return [...this.metrics].sort((a, b) => b.duration - a.duration).slice(0, count)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +71,7 @@ class ApiMonitor {
|
||||
getStatsByUrl(): Record<string, { count: number; avgDuration: number; successRate: number }> {
|
||||
const urlStats: Record<string, RequestMetrics[]> = {}
|
||||
|
||||
this.metrics.forEach((metric) => {
|
||||
this.metrics.forEach(metric => {
|
||||
if (!urlStats[metric.url]) {
|
||||
urlStats[metric.url] = []
|
||||
}
|
||||
@@ -84,7 +82,7 @@ class ApiMonitor {
|
||||
|
||||
Object.entries(urlStats).forEach(([url, metrics]) => {
|
||||
const totalDuration = metrics.reduce((sum, m) => sum + m.duration, 0)
|
||||
const successCount = metrics.filter((m) => m.success).length
|
||||
const successCount = metrics.filter(m => m.success).length
|
||||
|
||||
result[url] = {
|
||||
count: metrics.length,
|
||||
@@ -113,7 +111,7 @@ class ApiMonitor {
|
||||
console.log('成功率:', this.getSuccessRate(), '%')
|
||||
console.log('最慢的 5 个请求:')
|
||||
console.table(
|
||||
this.getSlowestRequests(5).map((m) => ({
|
||||
this.getSlowestRequests(5).map(m => ({
|
||||
方法: m.method,
|
||||
URL: m.url,
|
||||
耗时: `${m.duration}ms`,
|
||||
|
||||
@@ -34,9 +34,8 @@ async function loadImage() {
|
||||
}
|
||||
try {
|
||||
const key = extractObjectKey(props.source)
|
||||
const blob = props.admin && key
|
||||
? await fetchAdminFileBlob(key)
|
||||
: await fetchFileBlobByURL(props.source)
|
||||
const blob =
|
||||
props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(props.source)
|
||||
objectURL.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
failed.value = true
|
||||
@@ -55,7 +54,7 @@ onBeforeUnmount(revokeCurrentURL)
|
||||
|
||||
<template>
|
||||
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
|
||||
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async">
|
||||
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
|
||||
</template>
|
||||
|
||||
@@ -11,19 +11,11 @@ function isNavActive(path: string) {
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav">
|
||||
<RouterLink
|
||||
to="/m"
|
||||
class="nav-item"
|
||||
:class="{ active: isNavActive('/m') }"
|
||||
>
|
||||
<RouterLink to="/m" class="nav-item" :class="{ active: isNavActive('/m') }">
|
||||
<van-icon name="home-o" :size="22" />
|
||||
<span>首页</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/m/messages"
|
||||
class="nav-item"
|
||||
:class="{ active: isNavActive('/m/messages') }"
|
||||
>
|
||||
<RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
|
||||
<van-icon name="chat-o" :size="22" />
|
||||
<span>消息</span>
|
||||
</RouterLink>
|
||||
@@ -31,19 +23,11 @@ function isNavActive(path: string) {
|
||||
<div class="publish-pill">+</div>
|
||||
<span>发布</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/m/orders"
|
||||
class="nav-item"
|
||||
:class="{ active: isNavActive('/m/orders') }"
|
||||
>
|
||||
<RouterLink to="/m/orders" class="nav-item" :class="{ active: isNavActive('/m/orders') }">
|
||||
<van-icon name="orders-o" :size="22" />
|
||||
<span>订单</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/m/profile"
|
||||
class="nav-item"
|
||||
:class="{ active: isNavActive('/m/profile') }"
|
||||
>
|
||||
<RouterLink to="/m/profile" class="nav-item" :class="{ active: isNavActive('/m/profile') }">
|
||||
<van-icon name="manager-o" :size="22" />
|
||||
<span>我的</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import type { ChargeMode, ListingPublishOptions, PublishSalePriceConfig, QuantityKey, ScreenshotKey } from '@/features/listings/api/listingOptions'
|
||||
import type {
|
||||
ChargeMode,
|
||||
ListingPublishOptions,
|
||||
PublishSalePriceConfig,
|
||||
QuantityKey,
|
||||
ScreenshotKey,
|
||||
} from '@/features/listings/api/listingOptions'
|
||||
import type { PublishForm } from '@/types/publish'
|
||||
import {
|
||||
buildDepositBreakdownItems,
|
||||
@@ -39,15 +45,21 @@ export function usePricingCalculator(options: {
|
||||
const quantityItems = computed(() => options.publishOptions.value.quantity_items)
|
||||
const screenshotSlots = computed(() => options.publishOptions.value.screenshot_slots)
|
||||
const priceConfig = computed(() => options.publishOptions.value.price_config)
|
||||
const depositRecommendConfig = computed(() => options.publishOptions.value.deposit_recommend_config)
|
||||
const depositRecommendConfig = computed(
|
||||
() => options.publishOptions.value.deposit_recommend_config
|
||||
)
|
||||
const fireLevelMin = computed(() => options.publishOptions.value.fire_level_min || 38)
|
||||
const fireLevelPlaceholder = computed(() => `等级低于${fireLevelMin.value}级的号无法发布`)
|
||||
const coinMAmount = computed(() => Number(options.form.haf_coin_amount || 0))
|
||||
const coinWanAmount = computed(() => coinMAmount.value * 100)
|
||||
const dailyLossMAmount = computed(() => Number(options.form.daily_loss_m || 10))
|
||||
const dailyLossRatioAdjustment = computed(() => calculateDailyLossRatioAdjustment(dailyLossMAmount.value))
|
||||
const dailyLossRatioAdjustment = computed(() =>
|
||||
calculateDailyLossRatioAdjustment(dailyLossMAmount.value)
|
||||
)
|
||||
const screenshotUrls = computed(() =>
|
||||
screenshotSlots.value.flatMap((item) => options.screenshotFiles?.[item.key] || []).filter((url) => Boolean(url)),
|
||||
screenshotSlots.value
|
||||
.flatMap(item => options.screenshotFiles?.[item.key] || [])
|
||||
.filter(url => Boolean(url))
|
||||
)
|
||||
|
||||
function hasAcceleratedSaleRatioInput() {
|
||||
@@ -67,18 +79,18 @@ export function usePricingCalculator(options: {
|
||||
selectedSkins: options.selectedSkins.value,
|
||||
levelOptions: levelOptions.value,
|
||||
dailyLossRatioAdjustment: dailyLossRatioAdjustment.value,
|
||||
}),
|
||||
})
|
||||
)
|
||||
const calculatedDefaultSaleRatio = computed(() => calculatedSellerReferenceRatio.value)
|
||||
const maxAcceleratedSaleRatio = computed(() =>
|
||||
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0,
|
||||
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0
|
||||
)
|
||||
const calculatedRatio = computed(() =>
|
||||
readFinalSaleRatio(
|
||||
calculatedDefaultSaleRatio.value,
|
||||
options.form.accelerated_sale_ratio,
|
||||
maxAcceleratedSaleRatio.value,
|
||||
),
|
||||
maxAcceleratedSaleRatio.value
|
||||
)
|
||||
)
|
||||
const calculatedCoinBasePrice = computed(() => {
|
||||
if (calculatedRatio.value <= 0) return 0
|
||||
@@ -90,10 +102,12 @@ export function usePricingCalculator(options: {
|
||||
quantityValues: options.quantityValues,
|
||||
quantityModes: options.quantityModes,
|
||||
seasonInsurance: options.form.season_insurance,
|
||||
}),
|
||||
})
|
||||
)
|
||||
const calculatedSellerPrice = computed(() =>
|
||||
calculatedRatio.value > 0 ? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value) : 0,
|
||||
calculatedRatio.value > 0
|
||||
? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value)
|
||||
: 0
|
||||
)
|
||||
const calculatedPlatformPricing = computed(() =>
|
||||
calculatePlatformPricing({
|
||||
@@ -104,12 +118,16 @@ export function usePricingCalculator(options: {
|
||||
sellerTotalPrice: calculatedSellerPrice.value,
|
||||
consumablePrice: calculatedConsumablePrice.value,
|
||||
salePriceConfig: options.salePriceConfig.value,
|
||||
}),
|
||||
})
|
||||
)
|
||||
const calculatedFinalPrice = computed(() => calculatedPlatformPricing.value.buyerTotalPrice)
|
||||
const calculatedRatioText = computed(() => (calculatedRatio.value > 0 ? `1:${formatNumber(calculatedRatio.value)}` : '--'))
|
||||
const calculatedRatioText = computed(() =>
|
||||
calculatedRatio.value > 0 ? `1:${formatNumber(calculatedRatio.value)}` : '--'
|
||||
)
|
||||
const calculatedDefaultSaleRatioText = computed(() =>
|
||||
calculatedDefaultSaleRatio.value > 0 ? `1:${formatNumber(calculatedDefaultSaleRatio.value)}` : '--',
|
||||
calculatedDefaultSaleRatio.value > 0
|
||||
? `1:${formatNumber(calculatedDefaultSaleRatio.value)}`
|
||||
: '--'
|
||||
)
|
||||
const saleRatioRangeText = computed(() => {
|
||||
if (calculatedDefaultSaleRatio.value <= 0) return '完成基础信息后自动计算参考比例'
|
||||
@@ -124,14 +142,14 @@ export function usePricingCalculator(options: {
|
||||
depositRecommendConfig: depositRecommendConfig.value,
|
||||
skinGroups: skinGroups.value,
|
||||
selectedSkins: options.selectedSkins.value,
|
||||
}),
|
||||
})
|
||||
)
|
||||
const depositBreakdownItems = computed(() =>
|
||||
buildDepositBreakdownItems({
|
||||
depositRecommendConfig: depositRecommendConfig.value,
|
||||
skinGroups: skinGroups.value,
|
||||
selectedSkins: options.selectedSkins.value,
|
||||
}),
|
||||
})
|
||||
)
|
||||
const platformRuleLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -139,7 +157,9 @@ export function usePricingCalculator(options: {
|
||||
ratio_subtract: '比例修正',
|
||||
none: '无加价',
|
||||
}
|
||||
return labels[calculatedPlatformPricing.value.ruleType] || calculatedPlatformPricing.value.ruleType
|
||||
return (
|
||||
labels[calculatedPlatformPricing.value.ruleType] || calculatedPlatformPricing.value.ruleType
|
||||
)
|
||||
})
|
||||
const publishTitle = computed(() => {
|
||||
const parts = [
|
||||
|
||||
@@ -1,63 +1,62 @@
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import { showToast } from "vant";
|
||||
import { sendSmsCode } from "@/features/auth/api/auth";
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { sendSmsCode } from '@/features/auth/api/auth'
|
||||
|
||||
export function useSmsCountdown() {
|
||||
const countDown = ref(0);
|
||||
const sending = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
const countDown = ref(0)
|
||||
const sending = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startCountDown() {
|
||||
countDown.value = 60;
|
||||
countDown.value = 60
|
||||
timer = setInterval(() => {
|
||||
countDown.value--;
|
||||
countDown.value--
|
||||
if (countDown.value <= 0) {
|
||||
clearInterval(timer!);
|
||||
timer = null;
|
||||
clearInterval(timer!)
|
||||
timer = null
|
||||
}
|
||||
}, 1000);
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
async function handleSendCode(phone: string) {
|
||||
if (!phone.trim()) {
|
||||
showToast({ message: "请输入手机号", icon: "warning-o" });
|
||||
return false;
|
||||
showToast({ message: '请输入手机号', icon: 'warning-o' })
|
||||
return false
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
sending.value = true
|
||||
try {
|
||||
await sendSmsCode(phone);
|
||||
await sendSmsCode(phone)
|
||||
showToast({
|
||||
message: "验证码已发送,请注意查收",
|
||||
icon: "passed",
|
||||
});
|
||||
startCountDown();
|
||||
return true;
|
||||
message: '验证码已发送,请注意查收',
|
||||
icon: 'passed',
|
||||
})
|
||||
startCountDown()
|
||||
return true
|
||||
} catch (error) {
|
||||
showToast({
|
||||
message: readError(error, "验证码发送失败,请稍后重试"),
|
||||
icon: "cross",
|
||||
});
|
||||
return false;
|
||||
message: readError(error, '验证码发送失败,请稍后重试'),
|
||||
icon: 'cross',
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
sending.value = false;
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === "object" && error && "response" in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } })
|
||||
.response;
|
||||
return response?.data?.message || fallback;
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback;
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -65,5 +64,5 @@ export function useSmsCountdown() {
|
||||
sending,
|
||||
handleSendCode,
|
||||
readError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
transition: opacity 0.2s, width 0.3s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
width 0.3s;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -168,7 +170,9 @@
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
transition: opacity 0.2s, width 0.3s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
width 0.3s;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -256,8 +260,13 @@
|
||||
}
|
||||
|
||||
@keyframes loginPulse {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(5%, 5%); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(5%, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-login-panel {
|
||||
@@ -393,7 +402,9 @@ h1 {
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
@@ -990,7 +1001,9 @@ h1 {
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
@@ -1051,7 +1064,9 @@ h1 {
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.listing-card:hover {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
:root {
|
||||
color: #1f2933;
|
||||
background: #f6f8fb;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family:
|
||||
Inter,
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
@@ -649,13 +649,9 @@ h1 {
|
||||
.pc-hero-content {
|
||||
min-height: 360px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(17, 24, 39, 0.82),
|
||||
rgba(120, 53, 15, 0.72)
|
||||
),
|
||||
radial-gradient(circle at 82% 20%, rgba(251, 191, 36, 0.9), transparent 24%),
|
||||
#111827;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(17, 24, 39, 0.82), rgba(120, 53, 15, 0.72)),
|
||||
radial-gradient(circle at 82% 20%, rgba(251, 191, 36, 0.9), transparent 24%), #111827;
|
||||
padding: 48px;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
@@ -882,7 +878,9 @@ h1 {
|
||||
align-items: center;
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.resource-card:hover {
|
||||
|
||||
@@ -56,7 +56,13 @@ export const settlementStatuses = [
|
||||
] as const
|
||||
export type SettlementStatus = (typeof settlementStatuses)[number]
|
||||
|
||||
export const realnameStatuses = ['unknown', 'unverified', 'pending', 'verified', 'rejected'] as const
|
||||
export const realnameStatuses = [
|
||||
'unknown',
|
||||
'unverified',
|
||||
'pending',
|
||||
'verified',
|
||||
'rejected',
|
||||
] as const
|
||||
export type RealnameStatusValue = (typeof realnameStatuses)[number]
|
||||
|
||||
export const userStatuses = ['active', 'frozen', 'disabled'] as const
|
||||
|
||||
@@ -40,7 +40,7 @@ export function clearAuthStorage(scope: AuthScope) {
|
||||
const keys = keysFor(scope)
|
||||
localStorage.removeItem(keys.accessToken)
|
||||
localStorage.removeItem(keys.refreshToken)
|
||||
keys.profile.forEach((key) => localStorage.removeItem(key))
|
||||
keys.profile.forEach(key => localStorage.removeItem(key))
|
||||
notifyAuthStorageChanged(scope)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
avatar: 512,
|
||||
chat: 1280,
|
||||
"home-banner": 1920,
|
||||
'home-banner': 1920,
|
||||
listing: 1920,
|
||||
dispute: 1920,
|
||||
handoff: 1920,
|
||||
realname: 1920,
|
||||
};
|
||||
}
|
||||
|
||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
avatar: 0.82,
|
||||
chat: 0.8,
|
||||
"home-banner": 0.84,
|
||||
'home-banner': 0.84,
|
||||
listing: 0.84,
|
||||
dispute: 0.86,
|
||||
handoff: 0.86,
|
||||
realname: 0.86,
|
||||
};
|
||||
}
|
||||
|
||||
export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
if (!file.type.startsWith("image/")) return file;
|
||||
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) return file;
|
||||
if (typeof document === "undefined") return file;
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) return file
|
||||
if (typeof document === 'undefined') return file
|
||||
|
||||
try {
|
||||
const image = await loadImage(file);
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] || 1600;
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] || 0.82;
|
||||
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return file;
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const blob = await canvasToBlob(canvas, "image/webp", quality);
|
||||
if (!blob || blob.size >= file.size) return file;
|
||||
return new File([blob], replaceFileExt(file.name, "webp"), {
|
||||
type: "image/webp",
|
||||
const image = await loadImage(file)
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] || 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] || 0.82
|
||||
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return file
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
const blob = await canvasToBlob(canvas, 'image/webp', quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], replaceFileExt(file.name, 'webp'), {
|
||||
type: 'image/webp',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
})
|
||||
} catch {
|
||||
return file;
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("图片读取失败"));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function fitSize(width: number, height: number, maxSide: number) {
|
||||
if (width <= 0 || height <= 0) return { width: 1, height: 1 };
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height));
|
||||
if (width <= 0 || height <= 0) return { width: 1, height: 1 }
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height))
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, type, quality);
|
||||
});
|
||||
return new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceFileExt(filename: string, ext: string) {
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return `${base || "image"}.${ext}`;
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
return `${base || 'image'}.${ext}`
|
||||
}
|
||||
|
||||
@@ -1,307 +1,310 @@
|
||||
import type { Listing } from "@/features/listings/api/listings";
|
||||
import type { Listing } from '@/features/listings/api/listings'
|
||||
|
||||
export interface ListingDisplayChip {
|
||||
label: string;
|
||||
value: string;
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ListingDisplayResource {
|
||||
key: string;
|
||||
label: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
mode: string;
|
||||
amount: number;
|
||||
key: string
|
||||
label: string
|
||||
price: string
|
||||
quantity: number
|
||||
mode: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export function getCoinWan(item: Listing) {
|
||||
return Math.round(Number(item.haf_coin_amount || 0) / 10000);
|
||||
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
|
||||
}
|
||||
|
||||
export function getCoinM(item: Listing) {
|
||||
return getCoinWan(item) / 100;
|
||||
return getCoinWan(item) / 100
|
||||
}
|
||||
|
||||
export function formatListingCode(item: Listing) {
|
||||
return `SP${String(item.id).padStart(6, "0")}`;
|
||||
return `SP${String(item.id).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
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`;
|
||||
const amountM = amountWan / 100
|
||||
const rounded = Math.round(amountM * 10) / 10
|
||||
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`
|
||||
}
|
||||
|
||||
export function getListingDisplayPrice(item: Listing) {
|
||||
return Number(item.price || 0);
|
||||
return Number(item.price || 0)
|
||||
}
|
||||
|
||||
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)));
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
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)}` : "--";
|
||||
const ratio = getRatioValue(item)
|
||||
return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--'
|
||||
}
|
||||
|
||||
export function getValuePerYuanText(item: Listing) {
|
||||
return formatRatio(item);
|
||||
return formatRatio(item)
|
||||
}
|
||||
|
||||
export function getLoginMethod(item: Listing) {
|
||||
return item.login_platform.trim();
|
||||
return item.login_platform.trim()
|
||||
}
|
||||
|
||||
export function getServerRegion(item: Listing) {
|
||||
return item.server_region.trim();
|
||||
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", "六头"),
|
||||
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("/");
|
||||
].filter(Boolean)
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
export function getListingSubtitle(item: Listing) {
|
||||
return getValuePerYuanText(item);
|
||||
return getValuePerYuanText(item)
|
||||
}
|
||||
|
||||
export function getListingChips(item: Listing): ListingDisplayChip[] {
|
||||
const totalAsset = readAssetNumber(item, "total_asset_wan");
|
||||
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");
|
||||
{ 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}发` });
|
||||
chips.push({ label: 'AWM', value: `${awmAmmo}发` })
|
||||
}
|
||||
if (totalAsset > 0) {
|
||||
chips.push({ label: "总资产", value: formatHafCoinM(totalAsset) });
|
||||
chips.push({ label: '总资产', value: formatHafCoinM(totalAsset) })
|
||||
}
|
||||
const online = getOnlineTimeText(item);
|
||||
const online = getOnlineTimeText(item)
|
||||
if (online) {
|
||||
chips.push({ label: "方便上号", value: online });
|
||||
chips.push({ label: '方便上号', value: online })
|
||||
}
|
||||
return chips.filter((chip) => chip.value);
|
||||
return chips.filter(chip => chip.value)
|
||||
}
|
||||
|
||||
export function getListingResources(item: Listing): ListingDisplayResource[] {
|
||||
const resources = item.asset_summary?.resources;
|
||||
if (!Array.isArray(resources)) return [];
|
||||
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>;
|
||||
.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 : "",
|
||||
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 : "",
|
||||
mode: typeof row.mode === 'string' ? row.mode : '',
|
||||
amount:
|
||||
row.mode === "收费"
|
||||
? roundMoney(readUnknownNumber(row.quantity) * readUnitPrice(typeof row.price === "string" ? row.price : ""))
|
||||
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);
|
||||
});
|
||||
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;
|
||||
return getListingResources(item).find(resource => resource.key === resourceKey)?.quantity || 0
|
||||
}
|
||||
|
||||
export function hasGiftResources(item: Listing) {
|
||||
return getListingResources(item).some((resource) => resource.mode === "赠送");
|
||||
return getListingResources(item).some(resource => resource.mode === '赠送')
|
||||
}
|
||||
|
||||
export function hasAcceleratedSaleRatio(item: Listing) {
|
||||
if (item.is_accelerated_sale) return true;
|
||||
if (item.is_accelerated_sale) return true
|
||||
|
||||
const priceBreakdown = item.asset_summary?.price_breakdown;
|
||||
if (typeof priceBreakdown !== "object" || priceBreakdown === null) return false;
|
||||
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);
|
||||
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;
|
||||
if (referenceRatio <= 0) return false
|
||||
return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio
|
||||
}
|
||||
|
||||
export function getSkinGroup(item: Listing, groupKey: string) {
|
||||
const skinGroups = item.asset_summary?.skin_groups;
|
||||
const skinGroups = item.asset_summary?.skin_groups
|
||||
if (
|
||||
typeof skinGroups !== "object" ||
|
||||
typeof skinGroups !== 'object' ||
|
||||
skinGroups === null ||
|
||||
!Array.isArray((skinGroups as Record<string, unknown>)[groupKey])
|
||||
) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
return ((skinGroups as Record<string, unknown>)[groupKey] as unknown[]).filter(
|
||||
(skin): skin is string => typeof skin === "string"
|
||||
);
|
||||
(skin): skin is string => typeof skin === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function getSkinNames(item: Listing) {
|
||||
const skinGroups = item.asset_summary?.skin_groups;
|
||||
if (typeof skinGroups !== "object" || skinGroups === null) return [];
|
||||
const skinGroups = item.asset_summary?.skin_groups
|
||||
if (typeof skinGroups !== 'object' || skinGroups === null) return []
|
||||
return Object.values(skinGroups as Record<string, unknown>)
|
||||
.flatMap((group) => (Array.isArray(group) ? group : []))
|
||||
.filter((skin): skin is string => typeof skin === "string");
|
||||
.flatMap(group => (Array.isArray(group) ? group : []))
|
||||
.filter((skin): skin is string => typeof skin === 'string')
|
||||
}
|
||||
|
||||
export function assetRegions(item: Listing) {
|
||||
const regions = item.asset_summary?.common_regions;
|
||||
const regions = item.asset_summary?.common_regions
|
||||
return Array.isArray(regions)
|
||||
? regions.filter((region): region is string => typeof region === "string")
|
||||
: [];
|
||||
? regions.filter((region): region is string => typeof region === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
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 "";
|
||||
if (start === "全天" || end === "全天" || (start === "00:00" && end === "23:59")) return "全天";
|
||||
return `${start.replace(":00", "")}-${end.replace(":00", "")}点`;
|
||||
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 ''
|
||||
if (start === '全天' || end === '全天' || (start === '00:00' && end === '23:59')) return '全天'
|
||||
return `${start.replace(':00', '')}-${end.replace(':00', '')}点`
|
||||
}
|
||||
|
||||
export function getDailyLoss(item: Listing) {
|
||||
const dailyLossM = getDailyLossM(item);
|
||||
return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : "";
|
||||
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;
|
||||
const configuredLoss = readAssetNumber(item, 'daily_loss_m')
|
||||
if (configuredLoss > 0) return configuredLoss
|
||||
|
||||
const coinWan = getCoinWan(item);
|
||||
if (coinWan >= 30000) return 30;
|
||||
if (coinWan >= 10000) return 20;
|
||||
return 10;
|
||||
const coinWan = getCoinWan(item)
|
||||
if (coinWan >= 30000) return 30
|
||||
if (coinWan >= 10000) return 20
|
||||
return 10
|
||||
}
|
||||
|
||||
export function getEstimatedRentalDays(item: Listing) {
|
||||
const coinM = getCoinM(item);
|
||||
const dailyLossM = getDailyLossM(item);
|
||||
if (coinM <= 0 || dailyLossM <= 0) return 0;
|
||||
return coinM / dailyLossM;
|
||||
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))}天`;
|
||||
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 : "";
|
||||
const value = item.asset_summary?.[key]
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export function readAssetNumber(item: Listing, key: string) {
|
||||
return readUnknownNumber(item.asset_summary?.[key]);
|
||||
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;
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
return 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]);
|
||||
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+)?)/);
|
||||
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 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;
|
||||
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;
|
||||
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 value
|
||||
}
|
||||
return `${rows * cols}格`;
|
||||
return `${rows * cols}格`
|
||||
}
|
||||
|
||||
function formatLevelShort(value: string, suffix: string) {
|
||||
const level = value.match(/\d+/)?.[0];
|
||||
return level ? `${level}${suffix}` : value;
|
||||
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}` : "";
|
||||
const quantity = getResourceQuantity(item, key)
|
||||
return quantity > 0 ? `${quantity}${label}` : ''
|
||||
}
|
||||
|
||||
function roundMoney(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function formatRatioNumber(value: number) {
|
||||
const rounded = roundMoney(value);
|
||||
return rounded.toFixed(1);
|
||||
const rounded = roundMoney(value)
|
||||
return rounded.toFixed(1)
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1);
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,17 @@ import type {
|
||||
import type { DepositBreakdownItem, PublishForm, PublishPlatformPricing } from '@/types/publish'
|
||||
|
||||
export const dailyLossOptions = [10, 20, 30, 40, 50]
|
||||
export const commonOnlineTimes = ['00:00', '08:00', '10:00', '12:00', '14:00', '18:00', '20:00', '22:00', '23:59']
|
||||
export const commonOnlineTimes = [
|
||||
'00:00',
|
||||
'08:00',
|
||||
'10:00',
|
||||
'12:00',
|
||||
'14:00',
|
||||
'18:00',
|
||||
'20:00',
|
||||
'22:00',
|
||||
'23:59',
|
||||
]
|
||||
|
||||
/**
|
||||
* 将金额四舍五入到角精度(0.1元)
|
||||
@@ -58,7 +68,10 @@ export function isGridCardQuantityItem(item: { key: string; label: string }) {
|
||||
return item.key === 'gridCard9' || item.label.includes('9格体验卡')
|
||||
}
|
||||
|
||||
export function isQuantityItemDisabledForInsurance(item: { key: string; label: string }, seasonInsurance: string) {
|
||||
export function isQuantityItemDisabledForInsurance(
|
||||
item: { key: string; label: string },
|
||||
seasonInsurance: string
|
||||
) {
|
||||
return seasonInsurance === '3*3' && isGridCardQuantityItem(item)
|
||||
}
|
||||
|
||||
@@ -85,9 +98,9 @@ export function calculateRecommendedDeposit(options: {
|
||||
}) {
|
||||
const baseAmount = Number(options.depositRecommendConfig.base_amount || 0)
|
||||
const skinAmount = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => {
|
||||
const group = options.skinGroups.find((item) => item.key === rule.group_key)
|
||||
const group = options.skinGroups.find(item => item.key === rule.group_key)
|
||||
if (!group) return sum
|
||||
const selectedCount = group.options.filter((skin) => options.selectedSkins.includes(skin)).length
|
||||
const selectedCount = group.options.filter(skin => options.selectedSkins.includes(skin)).length
|
||||
return sum + selectedCount * Number(rule.amount_per_item || 0)
|
||||
}, 0)
|
||||
return roundMoney(baseAmount + skinAmount)
|
||||
@@ -106,9 +119,9 @@ export function buildDepositBreakdownItems(options: {
|
||||
},
|
||||
]
|
||||
for (const rule of options.depositRecommendConfig.skin_group_rules) {
|
||||
const group = options.skinGroups.find((item) => item.key === rule.group_key)
|
||||
const group = options.skinGroups.find(item => item.key === rule.group_key)
|
||||
if (!group) continue
|
||||
const count = group.options.filter((skin) => options.selectedSkins.includes(skin)).length
|
||||
const count = group.options.filter(skin => options.selectedSkins.includes(skin)).length
|
||||
if (count <= 0) continue
|
||||
items.push({
|
||||
label: rule.label,
|
||||
@@ -129,7 +142,8 @@ export function calculateSellerReferenceRatio(options: {
|
||||
dailyLossRatioAdjustment: number
|
||||
}) {
|
||||
const { coinMAmount, form, ratioConfig } = options
|
||||
if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) return 0
|
||||
if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level)
|
||||
return 0
|
||||
const baseRatio = getInsuranceBaseRatio(ratioConfig, form.season_insurance)
|
||||
if (baseRatio <= 0) return 0
|
||||
return (
|
||||
@@ -140,7 +154,11 @@ export function calculateSellerReferenceRatio(options: {
|
||||
)
|
||||
}
|
||||
|
||||
export function readFinalSaleRatio(defaultRatio: number, acceleratedSaleRatio: number | '', maxAcceleratedSaleRatio: number) {
|
||||
export function readFinalSaleRatio(
|
||||
defaultRatio: number,
|
||||
acceleratedSaleRatio: number | '',
|
||||
maxAcceleratedSaleRatio: number
|
||||
) {
|
||||
if (defaultRatio <= 0) return 0
|
||||
if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatio
|
||||
const ratio = Number(acceleratedSaleRatio)
|
||||
@@ -167,14 +185,18 @@ export function calculatePlatformPricing(options: {
|
||||
return buildPlatformPricing(
|
||||
roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)),
|
||||
'fixed_markup',
|
||||
options,
|
||||
options
|
||||
)
|
||||
}
|
||||
const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.coinMAmount)
|
||||
const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0
|
||||
const buyerRatio = options.sellerRatio - ratioSubtract
|
||||
if (buyerRatio > 0 && ratioRule) {
|
||||
return buildPlatformPricing(roundMoney(options.coinWanAmount / buyerRatio), 'ratio_subtract', options)
|
||||
return buildPlatformPricing(
|
||||
roundMoney(options.coinWanAmount / buyerRatio),
|
||||
'ratio_subtract',
|
||||
options
|
||||
)
|
||||
}
|
||||
return buildPlatformPricing(options.sellerCoinBasePrice, 'none', options)
|
||||
}
|
||||
@@ -196,7 +218,7 @@ function buildPlatformPricing(
|
||||
coinWanAmount: number
|
||||
sellerTotalPrice: number
|
||||
consumablePrice: number
|
||||
},
|
||||
}
|
||||
): PublishPlatformPricing {
|
||||
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice)
|
||||
return {
|
||||
@@ -211,13 +233,17 @@ function buildPlatformPricing(
|
||||
function findSaleFixedMarkupRule(config: PublishSalePriceConfig, coinMAmount: number) {
|
||||
return [...config.fixed_markup_rules]
|
||||
.sort((a, b) => a.min_m - b.min_m)
|
||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, coinMAmount, { includeLastMax: true }))
|
||||
.find((item, index, rules) =>
|
||||
isCoinInSaleRange(item, index, rules, coinMAmount, { includeLastMax: true })
|
||||
)
|
||||
}
|
||||
|
||||
function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, coinMAmount: number) {
|
||||
return [...config.ratio_adjustment_rules]
|
||||
.sort((a, b) => a.min_m - b.min_m)
|
||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, coinMAmount, { excludeFirstMin: true }))
|
||||
.find((item, index, rules) =>
|
||||
isCoinInSaleRange(item, index, rules, coinMAmount, { excludeFirstMin: true })
|
||||
)
|
||||
}
|
||||
|
||||
function isCoinInSaleRange(
|
||||
@@ -225,13 +251,15 @@ function isCoinInSaleRange(
|
||||
index: number,
|
||||
rules: Array<{ min_m: number; max_m: number }>,
|
||||
coinMAmount: number,
|
||||
options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {},
|
||||
options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {}
|
||||
) {
|
||||
const maxM = Number(item.max_m || 0)
|
||||
const minM = Number(item.min_m || 0)
|
||||
const minMatched = options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM
|
||||
const minMatched =
|
||||
options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM
|
||||
const isLastRule = index === rules.length - 1
|
||||
const maxMatched = maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM)
|
||||
const maxMatched =
|
||||
maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM)
|
||||
return minMatched && maxMatched
|
||||
}
|
||||
|
||||
@@ -240,8 +268,11 @@ function calculateEffectiveRatio(coinWanAmount: number, price: number) {
|
||||
return roundRatio(coinWanAmount / price)
|
||||
}
|
||||
|
||||
function getInsuranceBaseRatio(config: Pick<ListingPublishOptions['ratio_config'], 'insurance_base_ratios'>, insurance: string) {
|
||||
return config.insurance_base_ratios.find((item) => item.insurance === insurance)?.ratio || 0
|
||||
function getInsuranceBaseRatio(
|
||||
config: Pick<ListingPublishOptions['ratio_config'], 'insurance_base_ratios'>,
|
||||
insurance: string
|
||||
) {
|
||||
return config.insurance_base_ratios.find(item => item.insurance === insurance)?.ratio || 0
|
||||
}
|
||||
|
||||
function calculateConfigPenalty(
|
||||
@@ -251,7 +282,7 @@ function calculateConfigPenalty(
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
levelOptions: string[]
|
||||
},
|
||||
}
|
||||
) {
|
||||
return config.config_items.reduce((sum, item) => {
|
||||
return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0)
|
||||
@@ -265,10 +296,11 @@ function isRatioConfigItemMatched(
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
levelOptions: string[]
|
||||
},
|
||||
}
|
||||
) {
|
||||
if (item.kind === 'skin_group') return hasSelectedSkinGroup(item.group_key || '', options)
|
||||
if (item.kind === 'max_stamina') return isMaxLevel(options.form.stamina_level, options.levelOptions)
|
||||
if (item.kind === 'max_stamina')
|
||||
return isMaxLevel(options.form.stamina_level, options.levelOptions)
|
||||
if (item.kind === 'max_load') return isMaxLevel(options.form.load_level, options.levelOptions)
|
||||
return false
|
||||
}
|
||||
@@ -278,11 +310,11 @@ function hasSelectedSkinGroup(
|
||||
options: {
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
},
|
||||
}
|
||||
) {
|
||||
const group = options.skinGroups.find((item) => item.key === groupKey)
|
||||
const group = options.skinGroups.find(item => item.key === groupKey)
|
||||
if (!group) return false
|
||||
return group.options.some((skin) => options.selectedSkins.includes(skin))
|
||||
return group.options.some(skin => options.selectedSkins.includes(skin))
|
||||
}
|
||||
|
||||
function isMaxLevel(value: string, levelOptions: string[]) {
|
||||
@@ -297,6 +329,13 @@ function readLevelNumber(value: string) {
|
||||
return match ? Number(match[0]) : 0
|
||||
}
|
||||
|
||||
function getCoinCorrection(config: Pick<ListingPublishOptions['ratio_config'], 'coin_corrections'>, coinM: number) {
|
||||
return [...config.coin_corrections].sort((a, b) => b.threshold_m - a.threshold_m).find((item) => coinM > item.threshold_m)?.correction || 0
|
||||
function getCoinCorrection(
|
||||
config: Pick<ListingPublishOptions['ratio_config'], 'coin_corrections'>,
|
||||
coinM: number
|
||||
) {
|
||||
return (
|
||||
[...config.coin_corrections]
|
||||
.sort((a, b) => b.threshold_m - a.threshold_m)
|
||||
.find(item => coinM > item.threshold_m)?.correction || 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,6 @@ export function getSystemConfigSelectOptions(key: string) {
|
||||
}
|
||||
|
||||
export function formatSystemConfigSelectValue(key: string, value: string) {
|
||||
const option = getSystemConfigSelectOptions(key)?.find((item) => item.value === value)
|
||||
const option = getSystemConfigSelectOptions(key)?.find(item => item.value === value)
|
||||
return option?.label || null
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ export function formatDateTime(value: DateInput, fallback = '-') {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return typeof value === 'string' && value.trim() ? value.replace('T', ' ') : fallback
|
||||
}
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
].join('-') + ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
return (
|
||||
[date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate())].join('-') +
|
||||
` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDateMinute(value: DateInput, fallback = '-') {
|
||||
|
||||
Reference in New Issue
Block a user