feat: Features架构迁移 - P0和P1部分完成
## 完成的工作 ### P0: 基础设施准备 - 创建 features/ 和 shared/ 目录结构 - 迁移共享资源:API基础设施、工具函数、类型定义 - 迁移通用composables:useMoney, useSmsCountdown, usePricingCalculator - 迁移全局样式文件 - 建立模块化导出系统 ### P1.1: 钱包模块 (wallet) - 迁移 API: wallet.ts - 迁移 Views: WalletView.vue - 新增 Composable: useWallet.ts (封装钱包状态管理) - 更新导入路径到 shared/ ### P1.2: 聊天模块 (chats) - 迁移 API: chats.ts - 迁移 Views: ChatView, MessagesView (桌面+移动) - 迁移 Composables: useChatSSE.ts - 迁移 Components: ChatAttachmentImage.vue - 更新导入路径到 shared/ ## 技术改进 - 修复 shared/composables 导出问题 (default → 命名导出) - 修复 shared/api/client.ts 类型导入路径 - 建立清晰的模块边界和导出规范 ## 文档 - 添加完整的迁移计划文档 - 添加进度跟踪文档 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
10acca637e
commit
b5903a169f
@@ -0,0 +1,172 @@
|
||||
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
function showError(message: string) {
|
||||
const isMobile = window.location.pathname.startsWith('/m')
|
||||
if (isMobile) {
|
||||
showToast({ message, icon: 'cross' })
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
silent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
clearAuthStorage,
|
||||
getAccessToken,
|
||||
getLoginPath,
|
||||
getRefreshToken,
|
||||
setAuthTokens,
|
||||
type AuthScope,
|
||||
} from '@/utils/authStorage'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
export async function unwrapData<T>(request: Promise<AxiosResponse<ApiResponse<T>>>) {
|
||||
const { data } = await request
|
||||
return data.data
|
||||
}
|
||||
|
||||
type RetryRequest = {
|
||||
resolve: (token: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
type RefreshState = {
|
||||
refreshing: boolean
|
||||
pendingRequests: RetryRequest[]
|
||||
}
|
||||
|
||||
type RetriableRequestConfig = InternalAxiosRequestConfig & {
|
||||
_retry?: boolean
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
const refreshStates: Record<AuthScope, RefreshState> = {
|
||||
user: {
|
||||
refreshing: false,
|
||||
pendingRequests: [],
|
||||
},
|
||||
admin: {
|
||||
refreshing: false,
|
||||
pendingRequests: [],
|
||||
},
|
||||
}
|
||||
|
||||
function resolvePendingRequests(scope: AuthScope, token: string) {
|
||||
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
|
||||
pendingRequests.forEach(({ resolve }) => resolve(token))
|
||||
}
|
||||
|
||||
function rejectPendingRequests(scope: AuthScope, error: unknown) {
|
||||
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
|
||||
pendingRequests.forEach(({ reject }) => reject(error))
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(scope: AuthScope): Promise<string> {
|
||||
const refreshToken = getRefreshToken(scope)
|
||||
if (!refreshToken) throw new Error('no refresh token')
|
||||
|
||||
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
||||
const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 })
|
||||
const tokens = {
|
||||
access_token: data.data.access_token,
|
||||
refresh_token: data.data.refresh_token,
|
||||
}
|
||||
setAuthTokens(scope, tokens)
|
||||
return tokens.access_token
|
||||
}
|
||||
|
||||
function getRequestScope(url = ''): AuthScope {
|
||||
return url.startsWith('/admin') ? 'admin' : 'user'
|
||||
}
|
||||
|
||||
function redirectToLogin(scope: AuthScope) {
|
||||
clearAuthStorage(scope)
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
const loginPath = getLoginPath(scope, currentPath)
|
||||
if (currentPath.startsWith(loginPath)) return
|
||||
|
||||
if (scope === 'admin') {
|
||||
window.location.assign(loginPath)
|
||||
return
|
||||
}
|
||||
|
||||
window.location.assign(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config as RetriableRequestConfig | undefined
|
||||
if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) {
|
||||
if (originalRequest && !originalRequest.silent) {
|
||||
const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试'
|
||||
showError(msg)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const requestUrl = originalRequest.url || ''
|
||||
const scope = getRequestScope(requestUrl)
|
||||
if (isRefreshRequest(requestUrl)) {
|
||||
redirectToLogin(scope)
|
||||
if (originalRequest && !originalRequest.silent) {
|
||||
showError('登录已失效,请重新登录')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const state = refreshStates[scope]
|
||||
if (state.refreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
state.pendingRequests.push({ resolve, reject })
|
||||
}).then((newToken) => {
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
state.refreshing = true
|
||||
try {
|
||||
const newToken = await refreshAccessToken(scope)
|
||||
resolvePendingRequests(scope, newToken)
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
rejectPendingRequests(scope, refreshError)
|
||||
redirectToLogin(scope)
|
||||
if (originalRequest && !originalRequest.silent) {
|
||||
showError('会话已过期,请重新登录')
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
state.refreshing = false
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
// API 基础设施
|
||||
export * from './client'
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { fetchAdminFileBlob, fetchFileBlobByURL } from '@/api/files'
|
||||
|
||||
const props = defineProps<{
|
||||
source: string
|
||||
admin?: boolean
|
||||
}>()
|
||||
|
||||
const objectURL = ref('')
|
||||
const failed = ref(false)
|
||||
|
||||
function extractObjectKey(value: string) {
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin)
|
||||
return parsed.searchParams.get('key') || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function revokeCurrentURL() {
|
||||
if (!objectURL.value) return
|
||||
URL.revokeObjectURL(objectURL.value)
|
||||
objectURL.value = ''
|
||||
}
|
||||
|
||||
async function loadImage() {
|
||||
revokeCurrentURL()
|
||||
failed.value = false
|
||||
if (!props.source) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const key = extractObjectKey(props.source)
|
||||
const blob = props.admin && key
|
||||
? await fetchAdminFileBlob(key)
|
||||
: await fetchFileBlobByURL(props.source)
|
||||
objectURL.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
failed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openImage() {
|
||||
if (!objectURL.value) return
|
||||
window.open(objectURL.value, '_blank')
|
||||
}
|
||||
|
||||
watch(() => [props.source, props.admin] as const, loadImage, { immediate: true })
|
||||
|
||||
onBeforeUnmount(revokeCurrentURL)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
|
||||
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async">
|
||||
</button>
|
||||
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-image-button {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.chat-image-button img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-image-fallback {
|
||||
display: inline-block;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #eef2f7;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
function isNavActive(path: string) {
|
||||
if (path === '/m') return route.path === '/m'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav">
|
||||
<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') }"
|
||||
>
|
||||
<van-icon name="chat-o" :size="22" />
|
||||
<span>消息</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
|
||||
<div class="publish-pill">+</div>
|
||||
<span>发布</span>
|
||||
</RouterLink>
|
||||
<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') }"
|
||||
>
|
||||
<van-icon name="manager-o" :size="22" />
|
||||
<span>我的</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
height: calc(50px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #1477ff;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.publish-pill {
|
||||
width: 36px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
background: #ff6a00;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.nav-publish span {
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (min-width: 520px) {
|
||||
.bottom-nav {
|
||||
left: calc((100vw - 430px) / 2);
|
||||
right: calc((100vw - 430px) / 2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
// 通用 Composables
|
||||
export { useMoney } from './useMoney'
|
||||
export { useSmsCountdown } from './useSmsCountdown'
|
||||
export { usePricingCalculator } from './usePricingCalculator'
|
||||
@@ -0,0 +1,3 @@
|
||||
export function useMoney() {
|
||||
return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}`
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import type { ChargeMode, ListingPublishOptions, PublishSalePriceConfig, QuantityKey, ScreenshotKey } from '@/api/listingOptions'
|
||||
import type { PublishForm } from '@/types/publish'
|
||||
import {
|
||||
buildDepositBreakdownItems,
|
||||
calculateConsumablePrice,
|
||||
calculateDailyLossRatioAdjustment,
|
||||
calculatePlatformPricing,
|
||||
calculateRecommendedDeposit,
|
||||
calculateSellerReferenceRatio,
|
||||
formatNumber,
|
||||
hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue,
|
||||
isQuantityItemDisabledForInsurance,
|
||||
readFinalSaleRatio,
|
||||
roundMoney,
|
||||
roundRatio,
|
||||
} from '@/utils/pricing'
|
||||
|
||||
export function usePricingCalculator(options: {
|
||||
publishOptions: Ref<ListingPublishOptions>
|
||||
salePriceConfig: Ref<PublishSalePriceConfig>
|
||||
form: PublishForm
|
||||
quantityValues: Record<QuantityKey, number>
|
||||
quantityModes: Record<QuantityKey, ChargeMode>
|
||||
screenshotFiles: Record<ScreenshotKey, string>
|
||||
selectedSkins: Ref<string[]>
|
||||
}) {
|
||||
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
||||
const faceOptions = computed(() => options.publishOptions.value.face_options)
|
||||
const rankOptions = computed(() => options.publishOptions.value.rank_options)
|
||||
const insuranceOptions = computed(() => options.publishOptions.value.insurance_options)
|
||||
const levelOptions = computed(() => options.publishOptions.value.level_options)
|
||||
const loginMethodOptions = computed(() => options.publishOptions.value.login_method_options)
|
||||
const regionOptions = computed(() => options.publishOptions.value.region_options)
|
||||
const banRecordOptions = computed(() => options.publishOptions.value.ban_record_options)
|
||||
const banEvidenceOptions = computed(() => options.publishOptions.value.ban_evidence_options)
|
||||
const skinGroups = computed(() => options.publishOptions.value.skin_groups)
|
||||
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 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 screenshotUrls = computed(() =>
|
||||
screenshotSlots.value.map((item) => options.screenshotFiles?.[item.key]).filter((url): url is string => Boolean(url)),
|
||||
)
|
||||
|
||||
function hasAcceleratedSaleRatioInput() {
|
||||
return hasAcceleratedSaleRatioValue(options.form.accelerated_sale_ratio)
|
||||
}
|
||||
|
||||
function isQuantityItemDisabled(item: { key: string; label: string }) {
|
||||
return isQuantityItemDisabledForInsurance(item, options.form.season_insurance)
|
||||
}
|
||||
|
||||
const calculatedSellerReferenceRatio = computed(() =>
|
||||
calculateSellerReferenceRatio({
|
||||
coinMAmount: coinMAmount.value,
|
||||
form: options.form,
|
||||
ratioConfig: options.publishOptions.value.ratio_config,
|
||||
skinGroups: skinGroups.value,
|
||||
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,
|
||||
)
|
||||
const calculatedRatio = computed(() =>
|
||||
readFinalSaleRatio(
|
||||
calculatedDefaultSaleRatio.value,
|
||||
options.form.accelerated_sale_ratio,
|
||||
maxAcceleratedSaleRatio.value,
|
||||
),
|
||||
)
|
||||
const calculatedCoinBasePrice = computed(() => {
|
||||
if (calculatedRatio.value <= 0) return 0
|
||||
return roundMoney(coinWanAmount.value / calculatedRatio.value)
|
||||
})
|
||||
const calculatedConsumablePrice = computed(() =>
|
||||
calculateConsumablePrice({
|
||||
quantityItems: quantityItems.value,
|
||||
quantityValues: options.quantityValues,
|
||||
quantityModes: options.quantityModes,
|
||||
seasonInsurance: options.form.season_insurance,
|
||||
}),
|
||||
)
|
||||
const calculatedSellerPrice = computed(() =>
|
||||
calculatedRatio.value > 0 ? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value) : 0,
|
||||
)
|
||||
const calculatedPlatformPricing = computed(() =>
|
||||
calculatePlatformPricing({
|
||||
coinMAmount: coinMAmount.value,
|
||||
coinWanAmount: coinWanAmount.value,
|
||||
sellerRatio: calculatedRatio.value,
|
||||
sellerCoinBasePrice: calculatedCoinBasePrice.value,
|
||||
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 calculatedDefaultSaleRatioText = computed(() =>
|
||||
calculatedDefaultSaleRatio.value > 0 ? `1:${formatNumber(calculatedDefaultSaleRatio.value)}` : '--',
|
||||
)
|
||||
const saleRatioRangeText = computed(() => {
|
||||
if (calculatedDefaultSaleRatio.value <= 0) return '完成基础信息后自动计算参考比例'
|
||||
return `可设置 1:${formatNumber(calculatedDefaultSaleRatio.value)} ~ 1:${formatNumber(maxAcceleratedSaleRatio.value)}`
|
||||
})
|
||||
const acceleratedSaleRatioPlaceholder = computed(() => {
|
||||
if (calculatedDefaultSaleRatio.value <= 0) return '填写资料后自动生成可设置范围'
|
||||
return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}`
|
||||
})
|
||||
const recommendedDepositAmount = computed(() =>
|
||||
calculateRecommendedDeposit({
|
||||
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> = {
|
||||
fixed_markup: '固定加价',
|
||||
ratio_subtract: '比例修正',
|
||||
none: '无加价',
|
||||
}
|
||||
return labels[calculatedPlatformPricing.value.ruleType] || calculatedPlatformPricing.value.ruleType
|
||||
})
|
||||
const publishTitle = computed(() => {
|
||||
const parts = [
|
||||
options.form.server_region,
|
||||
options.form.rank_level,
|
||||
coinMAmount.value ? `${coinMAmount.value}M哈夫币` : '',
|
||||
].filter(Boolean)
|
||||
return parts.length ? parts.join(' ') : '待完善账号信息'
|
||||
})
|
||||
|
||||
return {
|
||||
serverOptions,
|
||||
faceOptions,
|
||||
rankOptions,
|
||||
insuranceOptions,
|
||||
levelOptions,
|
||||
loginMethodOptions,
|
||||
regionOptions,
|
||||
banRecordOptions,
|
||||
banEvidenceOptions,
|
||||
skinGroups,
|
||||
quantityItems,
|
||||
screenshotSlots,
|
||||
priceConfig,
|
||||
depositRecommendConfig,
|
||||
fireLevelMin,
|
||||
fireLevelPlaceholder,
|
||||
coinMAmount,
|
||||
coinWanAmount,
|
||||
dailyLossMAmount,
|
||||
dailyLossRatioAdjustment,
|
||||
screenshotUrls,
|
||||
calculatedSellerReferenceRatio,
|
||||
calculatedDefaultSaleRatio,
|
||||
maxAcceleratedSaleRatio,
|
||||
calculatedRatio,
|
||||
calculatedCoinBasePrice,
|
||||
calculatedConsumablePrice,
|
||||
calculatedSellerPrice,
|
||||
calculatedPlatformPricing,
|
||||
calculatedFinalPrice,
|
||||
calculatedRatioText,
|
||||
calculatedDefaultSaleRatioText,
|
||||
saleRatioRangeText,
|
||||
acceleratedSaleRatioPlaceholder,
|
||||
recommendedDepositAmount,
|
||||
depositBreakdownItems,
|
||||
platformRuleLabel,
|
||||
publishTitle,
|
||||
hasAcceleratedSaleRatioInput,
|
||||
isQuantityItemDisabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import { showToast } from "vant";
|
||||
import { sendSmsCode } from "@/api/auth";
|
||||
|
||||
export function useSmsCountdown() {
|
||||
const countDown = ref(0);
|
||||
const sending = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startCountDown() {
|
||||
countDown.value = 60;
|
||||
timer = setInterval(() => {
|
||||
countDown.value--;
|
||||
if (countDown.value <= 0) {
|
||||
clearInterval(timer!);
|
||||
timer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSendCode(phone: string) {
|
||||
if (!phone.trim()) {
|
||||
showToast({ message: "请输入手机号", icon: "warning-o" });
|
||||
return false;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
try {
|
||||
await sendSmsCode(phone);
|
||||
showToast({
|
||||
message: "验证码已发送,请注意查收",
|
||||
icon: "passed",
|
||||
});
|
||||
startCountDown();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showToast({
|
||||
message: readError(error, "验证码发送失败,请稍后重试"),
|
||||
icon: "cross",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
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;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return {
|
||||
countDown,
|
||||
sending,
|
||||
handleSendCode,
|
||||
readError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// 共享资源导出
|
||||
export * from './api'
|
||||
export * from './composables'
|
||||
export * from './utils'
|
||||
export * as types from './types'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
color: #1f2933;
|
||||
background: #f6f8fb;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Vant Toast 保护规则 ──────────────────────────────────
|
||||
.van-popup 声明 background: var(--van-popup-background) = #fff (白色)
|
||||
.van-toast 声明 background: var(--van-toast-background) = rgba(0,0,0,.7) (黑色)
|
||||
两个类同优先级,但 .van-popup 在 vant/lib/index.css 中后声明 → 覆盖黑色背景
|
||||
导致 Toast 白底白字看不见。用双类选择器提升优先级强制使用黑色背景。
|
||||
────────────────────────────────────────────────────────── */
|
||||
.van-popup.van-toast {
|
||||
background: var(--van-toast-background) !important;
|
||||
color: var(--van-toast-text-color) !important;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* 筛选器相关样式 */
|
||||
.horizontal-filter-card {
|
||||
padding: 24px;
|
||||
border: 1px solid #eef1f5;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(23, 35, 61, 0.03);
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-title strong {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #17233d;
|
||||
}
|
||||
|
||||
.filter-title span {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.filter-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
border-color: #ff6a00;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
border-color: #ff6a00;
|
||||
background: #fff7ed;
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.filter-chip.wide {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* 筛选器弹窗样式 */
|
||||
:global(.home-filter-popover) {
|
||||
padding: 8px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.filter-menu,
|
||||
.range-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-menu button,
|
||||
.range-menu button {
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-menu button:hover,
|
||||
.range-menu button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.filter-menu button.active,
|
||||
.range-menu button.active {
|
||||
background: #fff7ed;
|
||||
color: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.range-manual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.range-manual :deep(.el-input-number) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.range-manual span {
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 皮肤筛选器 */
|
||||
.skin-filter-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.skin-reset {
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.skin-reset:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.skin-reset.active {
|
||||
background: #fff7ed;
|
||||
color: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.skin-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skin-filter-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.skin-filter-title strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #17233d;
|
||||
}
|
||||
|
||||
.skin-filter-title button {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.skin-filter-title button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.skin-filter-title button.active {
|
||||
background: #fff7ed;
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
.skin-filter-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.skin-filter-options button {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.skin-filter-options button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.skin-filter-options button.active {
|
||||
background: #fff7ed;
|
||||
color: #ff6a00;
|
||||
font-weight: 700;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
/* 性能优化相关样式 */
|
||||
|
||||
.lazy-image {
|
||||
background: #f1f5f9;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.component-loading,
|
||||
.component-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.component-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 骨架屏动画 */
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200px 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 全局类型定义
|
||||
export * from './types'
|
||||
export * from './status'
|
||||
export * from './publish'
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ChargeMode, QuantityKey, ScreenshotKey } from '@/api/listingOptions'
|
||||
|
||||
export type PublishForm = {
|
||||
server_region: string
|
||||
face_owner: string
|
||||
haf_coin_amount: number | ''
|
||||
rank_level: string
|
||||
secret_kd: string
|
||||
fire_level: number | ''
|
||||
daily_loss_m: number | ''
|
||||
accelerated_sale_ratio: number | ''
|
||||
season_insurance: string
|
||||
stamina_level: string
|
||||
load_level: string
|
||||
login_method: string
|
||||
online_start: string
|
||||
online_end: string
|
||||
ban_record: string
|
||||
common_regions: string[]
|
||||
deposit_amount: number | ''
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface PublishDraft {
|
||||
form: PublishForm
|
||||
quantityValues: Record<QuantityKey, number>
|
||||
quantityModes: Record<QuantityKey, ChargeMode>
|
||||
screenshotFiles: Record<ScreenshotKey, string>
|
||||
selectedSkins: string[]
|
||||
}
|
||||
|
||||
export interface DepositBreakdownItem {
|
||||
label: string
|
||||
amount: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface PublishPlatformPricing {
|
||||
buyerCoinBasePrice: number
|
||||
buyerTotalPrice: number
|
||||
buyerRatio: number
|
||||
platformMarkupAmount: number
|
||||
ruleType: 'fixed_markup' | 'ratio_subtract' | 'none' | string
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export const listingStatuses = ['draft', 'published', 'rented', 'offline', 'abnormal'] as const
|
||||
export type ListingStatus = (typeof listingStatuses)[number]
|
||||
|
||||
export const listingReviewStatuses = ['none', 'pending', 'approved', 'rejected'] as const
|
||||
export type ListingReviewStatus = (typeof listingReviewStatuses)[number]
|
||||
|
||||
export const orderStatuses = [
|
||||
'pending_confirm',
|
||||
'pending_payment',
|
||||
'pending_handoff',
|
||||
'renting',
|
||||
'overdue',
|
||||
'pending_return_confirm',
|
||||
'pending_checkout_confirm',
|
||||
'pending_checkout_accept',
|
||||
'checkout_disputing',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'closed',
|
||||
'disputing',
|
||||
'abnormal',
|
||||
] as const
|
||||
export type OrderStatus = (typeof orderStatuses)[number]
|
||||
|
||||
export const handoffStatuses = [
|
||||
'pending_owner',
|
||||
'pending_renter_confirm',
|
||||
'received',
|
||||
'pending_owner_return_confirm',
|
||||
'pending_owner_checkout',
|
||||
'pending_renter_checkout',
|
||||
'checkout_disputed',
|
||||
'returned',
|
||||
'cancelled',
|
||||
'owner_timeout',
|
||||
'renter_confirm_timeout',
|
||||
'return_overdue',
|
||||
'owner_return_confirm_timeout',
|
||||
'owner_checkout_confirm_timeout',
|
||||
'admin_closed',
|
||||
'admin_abnormal',
|
||||
'arbitrated',
|
||||
] as const
|
||||
export type HandoffStatus = (typeof handoffStatuses)[number]
|
||||
|
||||
export const settlementStatuses = [
|
||||
'unsettled',
|
||||
'pending',
|
||||
'frozen',
|
||||
'settled',
|
||||
'refunded',
|
||||
'cancelled',
|
||||
'closed',
|
||||
'disputed',
|
||||
'arbitrated',
|
||||
] as const
|
||||
export type SettlementStatus = (typeof settlementStatuses)[number]
|
||||
|
||||
export const realnameStatuses = ['unknown', 'unverified', 'pending', 'verified', 'rejected'] as const
|
||||
export type RealnameStatusValue = (typeof realnameStatuses)[number]
|
||||
|
||||
export const userStatuses = ['active', 'frozen', 'disabled'] as const
|
||||
export type UserStatus = (typeof userStatuses)[number]
|
||||
|
||||
export const riskStatuses = ['normal', 'watch', 'restricted', 'blocked'] as const
|
||||
export type RiskStatus = (typeof riskStatuses)[number]
|
||||
|
||||
export const disputeStatuses = ['open', 'processing', 'resolved', 'closed'] as const
|
||||
export type DisputeStatus = (typeof disputeStatuses)[number]
|
||||
|
||||
export const walletStatuses = ['active', 'frozen', 'disabled'] as const
|
||||
export type WalletStatus = (typeof walletStatuses)[number]
|
||||
|
||||
export const ledgerDirections = ['in', 'out', 'freeze', 'unfreeze'] as const
|
||||
export type LedgerDirection = (typeof ledgerDirections)[number]
|
||||
|
||||
export const balanceTypes = ['available', 'frozen'] as const
|
||||
export type BalanceType = (typeof balanceTypes)[number]
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type AuthScope = 'user' | 'admin'
|
||||
|
||||
export interface AuthTokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
const userKeys = {
|
||||
accessToken: 'access_token',
|
||||
refreshToken: 'refresh_token',
|
||||
profile: ['user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'],
|
||||
}
|
||||
|
||||
const adminKeys = {
|
||||
accessToken: 'admin_access_token',
|
||||
refreshToken: 'admin_refresh_token',
|
||||
profile: ['admin_id', 'admin_username'],
|
||||
}
|
||||
|
||||
function keysFor(scope: AuthScope) {
|
||||
return scope === 'admin' ? adminKeys : userKeys
|
||||
}
|
||||
|
||||
export function getAccessToken(scope: AuthScope) {
|
||||
return localStorage.getItem(keysFor(scope).accessToken) || ''
|
||||
}
|
||||
|
||||
export function getRefreshToken(scope: AuthScope) {
|
||||
return localStorage.getItem(keysFor(scope).refreshToken) || ''
|
||||
}
|
||||
|
||||
export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) {
|
||||
const keys = keysFor(scope)
|
||||
localStorage.setItem(keys.accessToken, tokens.access_token)
|
||||
localStorage.setItem(keys.refreshToken, tokens.refresh_token)
|
||||
notifyAuthStorageChanged(scope)
|
||||
}
|
||||
|
||||
export function clearAuthStorage(scope: AuthScope) {
|
||||
const keys = keysFor(scope)
|
||||
localStorage.removeItem(keys.accessToken)
|
||||
localStorage.removeItem(keys.refreshToken)
|
||||
keys.profile.forEach((key) => localStorage.removeItem(key))
|
||||
notifyAuthStorageChanged(scope)
|
||||
}
|
||||
|
||||
export function getLoginPath(scope: AuthScope, currentPath: string) {
|
||||
if (scope === 'admin') return '/admin/login'
|
||||
return currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||
}
|
||||
|
||||
export function notifyAuthStorageChanged(scope: AuthScope) {
|
||||
window.dispatchEvent(new CustomEvent('auth-storage-changed', { detail: { scope } }))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
avatar: 512,
|
||||
chat: 1280,
|
||||
"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,
|
||||
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;
|
||||
|
||||
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",
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
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));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceFileExt(filename: string, ext: string) {
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return `${base || "image"}.${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// 通用工具函数
|
||||
export * from './authStorage'
|
||||
export * from './imageUpload'
|
||||
export * from './json'
|
||||
export * from './listingDisplay'
|
||||
export * from './pricing'
|
||||
export * from './statusLabels'
|
||||
export * from './systemConfigOptions'
|
||||
export * from './time'
|
||||
@@ -0,0 +1,8 @@
|
||||
export function safeParseJSON<T>(raw: string, fallback: T): T {
|
||||
if (!raw || !raw.trim()) return fallback
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Listing } from "@/api/listings";
|
||||
|
||||
export interface ListingDisplayChip {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ListingDisplayResource {
|
||||
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);
|
||||
}
|
||||
|
||||
export function getCoinM(item: Listing) {
|
||||
return getCoinWan(item) / 100;
|
||||
}
|
||||
|
||||
export function formatListingCode(item: Listing) {
|
||||
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`;
|
||||
}
|
||||
|
||||
export function getListingDisplayPrice(item: Listing) {
|
||||
return Number(item.price || 0);
|
||||
}
|
||||
|
||||
export function getListingRentPrice(item: Listing) {
|
||||
const buyerCoinBasePrice = readPriceBreakdownNumber(item, "buyer_coin_base_price");
|
||||
if (buyerCoinBasePrice > 0) return Math.round(buyerCoinBasePrice);
|
||||
return Math.max(0, Math.round(getListingDisplayPrice(item) - getListingConsumablePrice(item)));
|
||||
}
|
||||
|
||||
export function getListingConsumablePrice(item: Listing) {
|
||||
const consumablePrice = readPriceBreakdownNumber(item, "consumable_price");
|
||||
if (consumablePrice > 0) return Math.round(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 });
|
||||
}
|
||||
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 === "收费"
|
||||
? Math.round(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 getSkinNames(item: Listing) {
|
||||
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");
|
||||
}
|
||||
|
||||
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")
|
||||
: [];
|
||||
}
|
||||
|
||||
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 `${start.replace(":00", "")}-${end.replace(":00", "")}点`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
ChargeMode,
|
||||
ListingPublishOptions,
|
||||
PublishDepositRecommendConfig,
|
||||
PublishOptionGroup,
|
||||
PublishQuantityItem,
|
||||
PublishRatioConfig,
|
||||
PublishSalePriceConfig,
|
||||
} from '@/api/listingOptions'
|
||||
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 function roundMoney(value: number) {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
export function roundRatio(value: number) {
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}`
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function calculateDailyLossRatioAdjustment(dailyLossMAmount: number) {
|
||||
return Math.min(Math.max(Math.floor((dailyLossMAmount - 10) / 10), 0), 4)
|
||||
}
|
||||
|
||||
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) {
|
||||
return seasonInsurance === '3*3' && isGridCardQuantityItem(item)
|
||||
}
|
||||
|
||||
export function calculateConsumablePrice(options: {
|
||||
quantityItems: PublishQuantityItem[]
|
||||
quantityValues: Record<string, number>
|
||||
quantityModes: Record<string, ChargeMode>
|
||||
seasonInsurance: string
|
||||
}) {
|
||||
const total = options.quantityItems.reduce((sum, item) => {
|
||||
const quantity = Number(options.quantityValues[item.key] || 0)
|
||||
const mode = options.quantityModes[item.key] || '收费'
|
||||
if (isQuantityItemDisabledForInsurance(item, options.seasonInsurance)) return sum
|
||||
if (quantity <= 0 || mode !== '收费') return sum
|
||||
return sum + quantity * readUnitPrice(item.price)
|
||||
}, 0)
|
||||
return roundMoney(total)
|
||||
}
|
||||
|
||||
export function calculateRecommendedDeposit(options: {
|
||||
depositRecommendConfig: PublishDepositRecommendConfig
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
}) {
|
||||
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)
|
||||
if (!group) return sum
|
||||
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)
|
||||
}
|
||||
|
||||
export function buildDepositBreakdownItems(options: {
|
||||
depositRecommendConfig: PublishDepositRecommendConfig
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
}): DepositBreakdownItem[] {
|
||||
const items: DepositBreakdownItem[] = [
|
||||
{
|
||||
label: '基础押金',
|
||||
amount: Number(options.depositRecommendConfig.base_amount || 0),
|
||||
count: 1,
|
||||
},
|
||||
]
|
||||
for (const rule of options.depositRecommendConfig.skin_group_rules) {
|
||||
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
|
||||
if (count <= 0) continue
|
||||
items.push({
|
||||
label: rule.label,
|
||||
amount: Number(rule.amount_per_item || 0) * count,
|
||||
count,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function calculateSellerReferenceRatio(options: {
|
||||
coinMAmount: number
|
||||
form: PublishForm
|
||||
ratioConfig: PublishRatioConfig
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
levelOptions: string[]
|
||||
dailyLossRatioAdjustment: number
|
||||
}) {
|
||||
const { coinMAmount, form, ratioConfig } = options
|
||||
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 (
|
||||
baseRatio +
|
||||
calculateConfigPenalty(ratioConfig, options) +
|
||||
getCoinCorrection(ratioConfig, coinMAmount) +
|
||||
options.dailyLossRatioAdjustment
|
||||
)
|
||||
}
|
||||
|
||||
export function readFinalSaleRatio(defaultRatio: number, acceleratedSaleRatio: number | '', maxAcceleratedSaleRatio: number) {
|
||||
if (defaultRatio <= 0) return 0
|
||||
if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatio
|
||||
const ratio = Number(acceleratedSaleRatio)
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio
|
||||
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio))
|
||||
}
|
||||
|
||||
export function hasAcceleratedSaleRatioInput(value: number | '') {
|
||||
return value !== '' && value !== null
|
||||
}
|
||||
|
||||
export function calculatePlatformPricing(options: {
|
||||
coinMAmount: number
|
||||
coinWanAmount: number
|
||||
sellerRatio: number
|
||||
sellerCoinBasePrice: number
|
||||
sellerTotalPrice: number
|
||||
consumablePrice: number
|
||||
salePriceConfig: PublishSalePriceConfig
|
||||
}): PublishPlatformPricing {
|
||||
if (options.sellerRatio <= 0 || options.sellerCoinBasePrice <= 0) return emptyPlatformPricing()
|
||||
const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.coinMAmount)
|
||||
if (fixedRule) {
|
||||
return buildPlatformPricing(
|
||||
roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)),
|
||||
'fixed_markup',
|
||||
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(options.sellerCoinBasePrice, 'none', options)
|
||||
}
|
||||
|
||||
export function emptyPlatformPricing(): PublishPlatformPricing {
|
||||
return {
|
||||
buyerCoinBasePrice: 0,
|
||||
buyerTotalPrice: 0,
|
||||
buyerRatio: 0,
|
||||
platformMarkupAmount: 0,
|
||||
ruleType: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlatformPricing(
|
||||
buyerCoinBasePrice: number,
|
||||
ruleType: string,
|
||||
options: {
|
||||
coinWanAmount: number
|
||||
sellerTotalPrice: number
|
||||
consumablePrice: number
|
||||
},
|
||||
): PublishPlatformPricing {
|
||||
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice)
|
||||
return {
|
||||
buyerCoinBasePrice,
|
||||
buyerTotalPrice,
|
||||
buyerRatio: calculateEffectiveRatio(options.coinWanAmount, buyerCoinBasePrice),
|
||||
platformMarkupAmount: roundMoney(buyerTotalPrice - options.sellerTotalPrice),
|
||||
ruleType,
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
function isCoinInSaleRange(
|
||||
item: { min_m: number; max_m: number },
|
||||
index: number,
|
||||
rules: Array<{ min_m: number; max_m: number }>,
|
||||
coinMAmount: number,
|
||||
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 isLastRule = index === rules.length - 1
|
||||
const maxMatched = maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM)
|
||||
return minMatched && maxMatched
|
||||
}
|
||||
|
||||
function calculateEffectiveRatio(coinWanAmount: number, price: number) {
|
||||
if (price <= 0) return 0
|
||||
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 calculateConfigPenalty(
|
||||
config: Pick<ListingPublishOptions['ratio_config'], 'config_items'>,
|
||||
options: {
|
||||
form: PublishForm
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
levelOptions: string[]
|
||||
},
|
||||
) {
|
||||
return config.config_items.reduce((sum, item) => {
|
||||
return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function isRatioConfigItemMatched(
|
||||
item: { kind: string; group_key?: string },
|
||||
options: {
|
||||
form: PublishForm
|
||||
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_load') return isMaxLevel(options.form.load_level, options.levelOptions)
|
||||
return false
|
||||
}
|
||||
|
||||
function hasSelectedSkinGroup(
|
||||
groupKey: string,
|
||||
options: {
|
||||
skinGroups: PublishOptionGroup[]
|
||||
selectedSkins: string[]
|
||||
},
|
||||
) {
|
||||
const group = options.skinGroups.find((item) => item.key === groupKey)
|
||||
if (!group) return false
|
||||
return group.options.some((skin) => options.selectedSkins.includes(skin))
|
||||
}
|
||||
|
||||
function isMaxLevel(value: string, levelOptions: string[]) {
|
||||
const currentLevel = readLevelNumber(value)
|
||||
const maxLevel = Math.max(...levelOptions.map(readLevelNumber).filter(Boolean))
|
||||
if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel
|
||||
return value === levelOptions[levelOptions.length - 1]
|
||||
}
|
||||
|
||||
function readLevelNumber(value: string) {
|
||||
const match = value.match(/\d+/)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
BalanceType,
|
||||
DisputeStatus,
|
||||
HandoffStatus,
|
||||
LedgerDirection,
|
||||
ListingReviewStatus,
|
||||
ListingStatus,
|
||||
OrderStatus,
|
||||
RealnameStatusValue,
|
||||
RiskStatus,
|
||||
SettlementStatus,
|
||||
UserStatus,
|
||||
WalletStatus,
|
||||
} from '@/types/status'
|
||||
|
||||
const listingStatusMap: Record<ListingStatus, string> = {
|
||||
draft: '草稿',
|
||||
published: '已上架',
|
||||
rented: '租用中',
|
||||
offline: '已下架',
|
||||
abnormal: '异常',
|
||||
}
|
||||
|
||||
const listingReviewStatusMap: Record<ListingReviewStatus, string> = {
|
||||
none: '未提交',
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
const orderStatusMap: Record<OrderStatus, string> = {
|
||||
pending_confirm: '待确认',
|
||||
pending_payment: '待支付',
|
||||
pending_handoff: '待交接',
|
||||
renting: '使用中',
|
||||
overdue: '已逾期',
|
||||
pending_return_confirm: '待结账确认',
|
||||
pending_checkout_confirm: '待号主确认结账',
|
||||
pending_checkout_accept: '待租客确认修正',
|
||||
checkout_disputing: '结账争议中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
closed: '已关闭',
|
||||
disputing: '申诉中',
|
||||
abnormal: '异常',
|
||||
}
|
||||
|
||||
const handoffStatusMap: Record<HandoffStatus, string> = {
|
||||
pending_owner: '待号主交接',
|
||||
pending_renter_confirm: '待租客确认',
|
||||
received: '已确认收号',
|
||||
pending_owner_return_confirm: '待号主确认结账',
|
||||
pending_owner_checkout: '待号主确认结账',
|
||||
pending_renter_checkout: '待租客确认修正',
|
||||
checkout_disputed: '结账争议中',
|
||||
returned: '已归还',
|
||||
cancelled: '已取消',
|
||||
owner_timeout: '号主交接超时',
|
||||
renter_confirm_timeout: '租客确认超时',
|
||||
return_overdue: '归还逾期',
|
||||
owner_return_confirm_timeout: '号主确认结账超时',
|
||||
owner_checkout_confirm_timeout: '号主确认结账超时',
|
||||
admin_closed: '客服关闭',
|
||||
admin_abnormal: '客服标记异常',
|
||||
arbitrated: '已仲裁',
|
||||
}
|
||||
|
||||
const settlementStatusMap: Record<SettlementStatus, string> = {
|
||||
unsettled: '未结算',
|
||||
pending: '待结算',
|
||||
frozen: '冻结中',
|
||||
settled: '已结算',
|
||||
refunded: '已退款',
|
||||
cancelled: '已取消',
|
||||
closed: '已关闭',
|
||||
disputed: '争议中',
|
||||
arbitrated: '已仲裁',
|
||||
}
|
||||
|
||||
const realnameStatusMap: Partial<Record<RealnameStatusValue, string>> = {
|
||||
unverified: '未认证',
|
||||
pending: '认证中',
|
||||
verified: '已认证',
|
||||
rejected: '认证失败',
|
||||
}
|
||||
|
||||
const userStatusMap: Record<UserStatus, string> = {
|
||||
active: '正常',
|
||||
frozen: '已冻结',
|
||||
disabled: '已禁用',
|
||||
}
|
||||
|
||||
const riskStatusMap: Record<RiskStatus, string> = {
|
||||
normal: '正常',
|
||||
watch: '观察',
|
||||
restricted: '受限',
|
||||
blocked: '已拦截',
|
||||
}
|
||||
|
||||
const disputeStatusMap: Record<DisputeStatus, string> = {
|
||||
open: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已处理',
|
||||
closed: '已关闭',
|
||||
}
|
||||
|
||||
const walletStatusMap: Record<WalletStatus, string> = {
|
||||
active: '正常',
|
||||
frozen: '已冻结',
|
||||
disabled: '已禁用',
|
||||
}
|
||||
|
||||
const ledgerDirectionMap: Record<LedgerDirection, string> = {
|
||||
in: '收入',
|
||||
out: '支出',
|
||||
freeze: '冻结',
|
||||
unfreeze: '解冻',
|
||||
}
|
||||
|
||||
const balanceTypeMap: Record<BalanceType, string> = {
|
||||
available: '可用余额',
|
||||
frozen: '冻结余额',
|
||||
}
|
||||
|
||||
function readLabel(map: Record<string, string>, value: string) {
|
||||
return map[value] || value || '-'
|
||||
}
|
||||
|
||||
export function listingStatusLabel(status: string) {
|
||||
return readLabel(listingStatusMap, status)
|
||||
}
|
||||
|
||||
export function listingReviewStatusLabel(status: string) {
|
||||
return readLabel(listingReviewStatusMap, status)
|
||||
}
|
||||
|
||||
export function orderStatusLabel(status: string) {
|
||||
return readLabel(orderStatusMap, status)
|
||||
}
|
||||
|
||||
export function handoffStatusLabel(status: string) {
|
||||
return readLabel(handoffStatusMap, status)
|
||||
}
|
||||
|
||||
export function settlementStatusLabel(status: string) {
|
||||
return readLabel(settlementStatusMap, status)
|
||||
}
|
||||
|
||||
export function realnameStatusLabel(status: string) {
|
||||
return readLabel(realnameStatusMap, status)
|
||||
}
|
||||
|
||||
export function userStatusLabel(status: string) {
|
||||
return readLabel(userStatusMap, status)
|
||||
}
|
||||
|
||||
export function riskStatusLabel(status: string) {
|
||||
return readLabel(riskStatusMap, status)
|
||||
}
|
||||
|
||||
export function disputeStatusLabel(status: string) {
|
||||
return readLabel(disputeStatusMap, status)
|
||||
}
|
||||
|
||||
export function walletStatusLabel(status: string) {
|
||||
return readLabel(walletStatusMap, status)
|
||||
}
|
||||
|
||||
export function ledgerDirectionLabel(direction: string) {
|
||||
return readLabel(ledgerDirectionMap, direction)
|
||||
}
|
||||
|
||||
export function balanceTypeLabel(type: string) {
|
||||
return readLabel(balanceTypeMap, type)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface SystemConfigOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const shortTimeoutOptions: SystemConfigOption[] = [
|
||||
{ label: '不限时', value: '0' },
|
||||
{ label: '5 分钟', value: '5' },
|
||||
{ label: '10 分钟', value: '10' },
|
||||
{ label: '15 分钟', value: '15' },
|
||||
{ label: '30 分钟', value: '30' },
|
||||
{ label: '45 分钟', value: '45' },
|
||||
{ label: '60 分钟', value: '60' },
|
||||
{ label: '90 分钟', value: '90' },
|
||||
{ label: '120 分钟', value: '120' },
|
||||
]
|
||||
|
||||
const longTimeoutOptions: SystemConfigOption[] = [
|
||||
{ label: '不限时', value: '0' },
|
||||
{ label: '30 分钟', value: '30' },
|
||||
{ label: '60 分钟', value: '60' },
|
||||
{ label: '120 分钟', value: '120' },
|
||||
{ label: '180 分钟', value: '180' },
|
||||
{ label: '240 分钟', value: '240' },
|
||||
{ label: '360 分钟', value: '360' },
|
||||
{ label: '12 小时', value: '720' },
|
||||
{ label: '24 小时', value: '1440' },
|
||||
{ label: '48 小时', value: '2880' },
|
||||
]
|
||||
|
||||
const booleanOptions: SystemConfigOption[] = [
|
||||
{ label: '开启', value: 'true' },
|
||||
{ label: '关闭', value: 'false' },
|
||||
]
|
||||
|
||||
export const systemConfigSelectOptions: Record<string, SystemConfigOption[]> = {
|
||||
'handoff.owner_submit_timeout_minutes': shortTimeoutOptions,
|
||||
'handoff.renter_confirm_timeout_minutes': shortTimeoutOptions,
|
||||
'handoff.owner_return_confirm_timeout_minutes': longTimeoutOptions,
|
||||
'order.pending_payment_timeout_minutes': shortTimeoutOptions,
|
||||
'order.return_overdue_grace_minutes': [
|
||||
{ label: '无宽限', value: '0' },
|
||||
{ label: '5 分钟', value: '5' },
|
||||
{ label: '10 分钟', value: '10' },
|
||||
{ label: '15 分钟', value: '15' },
|
||||
{ label: '30 分钟', value: '30' },
|
||||
{ label: '60 分钟', value: '60' },
|
||||
{ label: '120 分钟', value: '120' },
|
||||
],
|
||||
'listing.review_required': booleanOptions,
|
||||
'chat.default_support_admin_id': [
|
||||
{ label: '管理员 ID 1', value: '1' },
|
||||
{ label: '管理员 ID 2', value: '2' },
|
||||
{ label: '管理员 ID 3', value: '3' },
|
||||
],
|
||||
}
|
||||
|
||||
export function getSystemConfigSelectOptions(key: string) {
|
||||
return systemConfigSelectOptions[key] || null
|
||||
}
|
||||
|
||||
export function formatSystemConfigSelectValue(key: string, value: string) {
|
||||
const option = getSystemConfigSelectOptions(key)?.find((item) => item.value === value)
|
||||
return option?.label || null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
type DateInput = string | number | Date | null | undefined
|
||||
|
||||
export function formatDateTime(value: DateInput, fallback = '-') {
|
||||
if (!value) return fallback
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
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())}`
|
||||
}
|
||||
|
||||
export function formatDateMinute(value: DateInput, fallback = '-') {
|
||||
const formatted = formatDateTime(value, fallback)
|
||||
return formatted === fallback ? fallback : formatted.slice(0, 16)
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
Reference in New Issue
Block a user