重构前端:抽离发布表单逻辑
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
@@ -61,7 +63,6 @@ export async function logoutAdmin() {
|
|||||||
export async function refreshAdminSession() {
|
export async function refreshAdminSession() {
|
||||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||||
if (!refreshToken) throw new Error('no refresh token')
|
if (!refreshToken) throw new Error('no refresh token')
|
||||||
const axios = (await import('axios')).default
|
|
||||||
const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken })
|
const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken })
|
||||||
return data.data as AdminTokenPair
|
return data.data as AdminTokenPair
|
||||||
}
|
}
|
||||||
@@ -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,122 @@
|
|||||||
|
import type { ChargeMode, QuantityKey, ScreenshotKey } from '@/api/listingOptions'
|
||||||
|
import type { PublishDraft, PublishForm } from '@/types/publish'
|
||||||
|
|
||||||
|
export function defaultPublishForm(): PublishForm {
|
||||||
|
return {
|
||||||
|
server_region: '',
|
||||||
|
face_owner: '',
|
||||||
|
haf_coin_amount: '',
|
||||||
|
rank_level: '',
|
||||||
|
secret_kd: '',
|
||||||
|
fire_level: '',
|
||||||
|
daily_loss_m: 10,
|
||||||
|
accelerated_sale_ratio: '',
|
||||||
|
season_insurance: '',
|
||||||
|
stamina_level: '',
|
||||||
|
load_level: '',
|
||||||
|
login_method: '',
|
||||||
|
online_start: '',
|
||||||
|
online_end: '',
|
||||||
|
ban_record: '',
|
||||||
|
common_regions: [],
|
||||||
|
deposit_amount: '',
|
||||||
|
remark: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublishDraft(options: {
|
||||||
|
form: PublishForm
|
||||||
|
quantityValues: Record<QuantityKey, number>
|
||||||
|
quantityModes: Record<QuantityKey, ChargeMode>
|
||||||
|
screenshotFiles: Record<ScreenshotKey, string>
|
||||||
|
selectedSkins: string[]
|
||||||
|
}): PublishDraft {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...options.form,
|
||||||
|
common_regions: [...options.form.common_regions],
|
||||||
|
},
|
||||||
|
quantityValues: { ...options.quantityValues },
|
||||||
|
quantityModes: { ...options.quantityModes },
|
||||||
|
screenshotFiles: { ...options.screenshotFiles },
|
||||||
|
selectedSkins: [...options.selectedSkins],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPublishDraft(draftKey: string) {
|
||||||
|
const raw = localStorage.getItem(draftKey)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const draft = JSON.parse(raw) as Partial<PublishDraft>
|
||||||
|
return {
|
||||||
|
form: normalizeDraftForm(draft.form),
|
||||||
|
quantityValues: normalizeNumberRecord(draft.quantityValues),
|
||||||
|
quantityModes: normalizeQuantityModes(draft.quantityModes),
|
||||||
|
screenshotFiles: normalizeStringRecord(draft.screenshotFiles),
|
||||||
|
selectedSkins: Array.isArray(draft.selectedSkins)
|
||||||
|
? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string')
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(draftKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePublishDraft(draftKey: string, draft: PublishDraft) {
|
||||||
|
localStorage.setItem(draftKey, JSON.stringify(draft))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePublishDraft(draftKey: string) {
|
||||||
|
localStorage.removeItem(draftKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRecord(record: Record<string, unknown>) {
|
||||||
|
for (const key of Object.keys(record)) delete record[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDraftForm(value: unknown): PublishForm {
|
||||||
|
const next = defaultPublishForm()
|
||||||
|
if (!isRecord(value)) return next
|
||||||
|
for (const key of Object.keys(next) as Array<keyof PublishForm>) {
|
||||||
|
if (key === 'common_regions') continue
|
||||||
|
const draftValue = value[key]
|
||||||
|
if (draftValue !== undefined) next[key] = draftValue as never
|
||||||
|
}
|
||||||
|
next.common_regions = Array.isArray(value.common_regions)
|
||||||
|
? value.common_regions.filter((region): region is string => typeof region === 'string')
|
||||||
|
: []
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumberRecord(value: unknown) {
|
||||||
|
const record: Record<string, number> = {}
|
||||||
|
if (!isRecord(value)) return record
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
const parsed = Number(item)
|
||||||
|
if (Number.isFinite(parsed)) record[key] = parsed
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringRecord(value: unknown) {
|
||||||
|
const record: Record<string, string> = {}
|
||||||
|
if (!isRecord(value)) return record
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (typeof item === 'string') record[key] = item
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuantityModes(value: unknown) {
|
||||||
|
const record: Record<string, ChargeMode> = {}
|
||||||
|
if (!isRecord(value)) return record
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (item === '赠送' || item === '收费') record[key] = item
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchFileBlobByURL, uploadFile } from '@/api/files'
|
||||||
|
import {
|
||||||
|
emptyListingPublishOptions,
|
||||||
|
emptyListingSalePriceConfig,
|
||||||
|
fetchListingPublishOptions,
|
||||||
|
fetchListingSalePriceConfig,
|
||||||
|
type ChargeMode,
|
||||||
|
type ListingPublishOptions,
|
||||||
|
type PublishSalePriceConfig,
|
||||||
|
type ScreenshotKey,
|
||||||
|
} from '@/api/listingOptions'
|
||||||
|
import { createListing } from '@/api/listings'
|
||||||
|
import { usePricingCalculator } from '@/composables/usePricingCalculator'
|
||||||
|
import {
|
||||||
|
buildPublishDraft,
|
||||||
|
clearRecord,
|
||||||
|
defaultPublishForm,
|
||||||
|
readPublishDraft,
|
||||||
|
removePublishDraft,
|
||||||
|
writePublishDraft,
|
||||||
|
} from '@/composables/usePublishDraft'
|
||||||
|
import type { PublishForm } from '@/types/publish'
|
||||||
|
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
|
||||||
|
|
||||||
|
interface UsePublishFormOptions {
|
||||||
|
draftKey: string
|
||||||
|
submitSuccessPath: string
|
||||||
|
persistBeforeUnload?: boolean
|
||||||
|
confirmReset?: () => Promise<void>
|
||||||
|
notifySuccess: (message: string) => void
|
||||||
|
notifyWarning: (message: string) => void
|
||||||
|
notifyError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublishForm(options: UsePublishFormOptions) {
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const suppressDraftSave = ref(false)
|
||||||
|
const draftReady = ref(false)
|
||||||
|
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||||||
|
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const activeUploadKey = ref<ScreenshotKey>('coin')
|
||||||
|
const form = reactive<PublishForm>(defaultPublishForm())
|
||||||
|
const quantityValues = reactive<Record<string, number>>({})
|
||||||
|
const quantityModes = reactive<Record<string, ChargeMode>>({})
|
||||||
|
const screenshotFiles = reactive<Record<string, string>>({})
|
||||||
|
const screenshotPreviews = reactive<Record<string, string>>({})
|
||||||
|
const selectedSkins = ref<string[]>([])
|
||||||
|
|
||||||
|
const pricing = usePricingCalculator({
|
||||||
|
form,
|
||||||
|
publishOptions,
|
||||||
|
salePriceConfig,
|
||||||
|
quantityValues,
|
||||||
|
quantityModes,
|
||||||
|
screenshotFiles,
|
||||||
|
selectedSkins,
|
||||||
|
})
|
||||||
|
const uploadedScreenshotCount = computed(() => pricing.screenshotUrls.value.length)
|
||||||
|
const requiredScreenshotCount = ref(0)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
restoreDraft()
|
||||||
|
draftReady.value = true
|
||||||
|
if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
loadPublishOptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
saveDraft({ force: true })
|
||||||
|
if (options.persistBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
revokeAllScreenshotPreviews()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
|
||||||
|
() => {
|
||||||
|
saveDraft()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
pricing.recommendedDepositAmount,
|
||||||
|
() => {
|
||||||
|
syncRecommendedDeposit()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => form.season_insurance, pricing.quantityItems],
|
||||||
|
() => {
|
||||||
|
clearForbiddenQuantityItems()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[pricing.screenshotSlots, () => form.ban_record],
|
||||||
|
() => {
|
||||||
|
requiredScreenshotCount.value = pricing.screenshotSlots.value.filter((item) => isScreenshotRequired(item)).length
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadPublishOptions() {
|
||||||
|
try {
|
||||||
|
const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
|
||||||
|
fetchListingPublishOptions(),
|
||||||
|
fetchListingSalePriceConfig(),
|
||||||
|
])
|
||||||
|
publishOptions.value = nextPublishOptions
|
||||||
|
salePriceConfig.value = nextSalePriceConfig
|
||||||
|
} catch {
|
||||||
|
publishOptions.value = emptyListingPublishOptions
|
||||||
|
salePriceConfig.value = emptyListingSalePriceConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft(saveOptions: { force?: boolean } = {}) {
|
||||||
|
if (suppressDraftSave.value) return
|
||||||
|
if (!saveOptions.force && !draftReady.value) return
|
||||||
|
writePublishDraft(
|
||||||
|
options.draftKey,
|
||||||
|
buildPublishDraft({
|
||||||
|
form,
|
||||||
|
quantityValues,
|
||||||
|
quantityModes,
|
||||||
|
screenshotFiles,
|
||||||
|
selectedSkins: selectedSkins.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveDraft() {
|
||||||
|
saveDraft({ force: true })
|
||||||
|
options.notifySuccess('草稿已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBeforeUnload() {
|
||||||
|
saveDraft({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
const draft = readPublishDraft(options.draftKey)
|
||||||
|
if (!draft) return
|
||||||
|
Object.assign(form, draft.form)
|
||||||
|
clearRecord(quantityValues)
|
||||||
|
clearRecord(quantityModes)
|
||||||
|
clearRecord(screenshotFiles)
|
||||||
|
Object.assign(quantityValues, draft.quantityValues)
|
||||||
|
Object.assign(quantityModes, draft.quantityModes)
|
||||||
|
Object.assign(screenshotFiles, draft.screenshotFiles)
|
||||||
|
selectedSkins.value = draft.selectedSkins
|
||||||
|
hydrateScreenshotPreviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDraftState() {
|
||||||
|
Object.assign(form, defaultPublishForm())
|
||||||
|
clearRecord(quantityValues)
|
||||||
|
clearRecord(quantityModes)
|
||||||
|
clearRecord(screenshotFiles)
|
||||||
|
revokeAllScreenshotPreviews()
|
||||||
|
selectedSkins.value = []
|
||||||
|
activeUploadKey.value = 'coin'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetDraft() {
|
||||||
|
try {
|
||||||
|
await options.confirmReset?.()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressDraftSave.value = true
|
||||||
|
resetDraftState()
|
||||||
|
removePublishDraft(options.draftKey)
|
||||||
|
options.notifySuccess('已重置')
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressDraftSave.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSkin(skin: string) {
|
||||||
|
selectedSkins.value = selectedSkins.value.includes(skin)
|
||||||
|
? selectedSkins.value.filter((item) => item !== skin)
|
||||||
|
: [...selectedSkins.value, skin]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRegion(region: string) {
|
||||||
|
form.common_regions = form.common_regions.includes(region)
|
||||||
|
? form.common_regions.filter((item) => item !== region)
|
||||||
|
: [...form.common_regions, region]
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload(key: ScreenshotKey) {
|
||||||
|
activeUploadKey.value = key
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScreenshotUpload(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const key = activeUploadKey.value
|
||||||
|
const previewURL = URL.createObjectURL(file)
|
||||||
|
setScreenshotPreview(key, previewURL)
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadFile(file, 'listing')
|
||||||
|
screenshotFiles[key] = uploaded.url
|
||||||
|
options.notifySuccess('截图已上传')
|
||||||
|
} catch (error) {
|
||||||
|
revokeScreenshotPreview(key)
|
||||||
|
options.notifyError(readError(error, '截图上传失败'))
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScreenshot(key: ScreenshotKey) {
|
||||||
|
screenshotFiles[key] = ''
|
||||||
|
revokeScreenshotPreview(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScreenshotPreviewURL(key: ScreenshotKey) {
|
||||||
|
return screenshotPreviews[key] || screenshotFiles[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScreenshotPreview(key: ScreenshotKey, previewURL: string) {
|
||||||
|
revokeScreenshotPreview(key)
|
||||||
|
screenshotPreviews[key] = previewURL
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeScreenshotPreview(key: ScreenshotKey) {
|
||||||
|
const previewURL = screenshotPreviews[key]
|
||||||
|
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
|
||||||
|
delete screenshotPreviews[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAllScreenshotPreviews() {
|
||||||
|
for (const key of Object.keys(screenshotPreviews)) revokeScreenshotPreview(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateScreenshotPreviews() {
|
||||||
|
for (const [key, fileURL] of Object.entries(screenshotFiles)) {
|
||||||
|
if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith('/api/files/object')) continue
|
||||||
|
try {
|
||||||
|
const blob = await fetchFileBlobByURL(fileURL)
|
||||||
|
setScreenshotPreview(key, URL.createObjectURL(blob))
|
||||||
|
} catch {
|
||||||
|
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFireLevelInput(value: string | number | undefined) {
|
||||||
|
if (value === '' || value === undefined) {
|
||||||
|
form.fire_level = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const level = Number(value)
|
||||||
|
form.fire_level = Number.isFinite(level) ? Math.trunc(level) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAcceleratedSaleRatioInput(value: string | number | undefined) {
|
||||||
|
if (value === '' || value === undefined) {
|
||||||
|
form.accelerated_sale_ratio = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ratio = Number(value)
|
||||||
|
form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRecommendedDeposit() {
|
||||||
|
const recommended = pricing.recommendedDepositAmount.value
|
||||||
|
if (recommended <= 0) return
|
||||||
|
const current = Number(form.deposit_amount)
|
||||||
|
if (form.deposit_amount === '' || !Number.isFinite(current) || current < recommended) {
|
||||||
|
form.deposit_amount = recommended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRecommendedDeposit() {
|
||||||
|
if (pricing.recommendedDepositAmount.value > 0) {
|
||||||
|
form.deposit_amount = pricing.recommendedDepositAmount.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForbiddenQuantityItems() {
|
||||||
|
for (const item of pricing.quantityItems.value) {
|
||||||
|
if (!pricing.isQuantityItemDisabled(item)) continue
|
||||||
|
quantityValues[item.key] = 0
|
||||||
|
quantityModes[item.key] = '赠送'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) {
|
||||||
|
if (pricing.isQuantityItemDisabled(item)) return
|
||||||
|
quantityModes[item.key] = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampAcceleratedSaleRatioInput() {
|
||||||
|
if (!pricing.hasAcceleratedSaleRatioInput() || pricing.calculatedDefaultSaleRatio.value <= 0) return
|
||||||
|
const ratio = Number(form.accelerated_sale_ratio)
|
||||||
|
if (!Number.isFinite(ratio)) {
|
||||||
|
form.accelerated_sale_ratio = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.accelerated_sale_ratio = roundRatio(
|
||||||
|
Math.min(Math.max(ratio, pricing.calculatedDefaultSaleRatio.value), pricing.maxAcceleratedSaleRatio.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useReferenceSaleRatio() {
|
||||||
|
form.accelerated_sale_ratio = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMaxAcceleratedSaleRatio() {
|
||||||
|
if (pricing.maxAcceleratedSaleRatio.value <= 0) return
|
||||||
|
form.accelerated_sale_ratio = pricing.maxAcceleratedSaleRatio.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const error = validateForm()
|
||||||
|
if (error) {
|
||||||
|
options.notifyWarning(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const listing = await createListing({
|
||||||
|
title: pricing.publishTitle.value,
|
||||||
|
description: form.remark,
|
||||||
|
server_region: form.server_region,
|
||||||
|
login_platform: form.login_method,
|
||||||
|
rank_level: form.rank_level,
|
||||||
|
haf_coin_amount: pricing.coinMAmount.value * 1000000,
|
||||||
|
asset_summary: buildAssetSummary(),
|
||||||
|
screenshot_urls: pricing.screenshotUrls.value,
|
||||||
|
price: pricing.calculatedFinalPrice.value,
|
||||||
|
deposit_amount: Number(form.deposit_amount),
|
||||||
|
})
|
||||||
|
removePublishDraft(options.draftKey)
|
||||||
|
suppressDraftSave.value = true
|
||||||
|
options.notifySuccess(
|
||||||
|
listing.status === 'published' && listing.review_status === 'approved'
|
||||||
|
? '发布成功,已上架'
|
||||||
|
: '发布成功,等待后台审核',
|
||||||
|
)
|
||||||
|
await router.push(options.submitSuccessPath)
|
||||||
|
} catch (error) {
|
||||||
|
options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!form.server_region) return '请选择区服'
|
||||||
|
if (pricing.coinMAmount.value <= 0) return '请填写哈夫币/M'
|
||||||
|
if (!form.rank_level) return '请选择段位'
|
||||||
|
if (!form.fire_level) return '请填写烽火等级'
|
||||||
|
if (Number(form.fire_level) < pricing.fireLevelMin.value) return `烽火等级低于${pricing.fireLevelMin.value}级的号无法发布`
|
||||||
|
if (!form.season_insurance) return '请选择赛季保险'
|
||||||
|
if (!form.stamina_level) return '请选择体力等级'
|
||||||
|
if (!form.load_level) return '请选择负重等级'
|
||||||
|
if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗'
|
||||||
|
if (pricing.hasAcceleratedSaleRatioInput()) {
|
||||||
|
const ratio = Number(form.accelerated_sale_ratio)
|
||||||
|
if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确'
|
||||||
|
if (pricing.calculatedDefaultSaleRatio.value > 0 && ratio < pricing.calculatedDefaultSaleRatio.value) {
|
||||||
|
return `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}`
|
||||||
|
}
|
||||||
|
if (pricing.maxAcceleratedSaleRatio.value > 0 && ratio > pricing.maxAcceleratedSaleRatio.value) {
|
||||||
|
return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio.value)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录'
|
||||||
|
if (form.deposit_amount === '' || Number(form.deposit_amount) < 0) return '请填写押金'
|
||||||
|
if (!Number.isFinite(Number(form.deposit_amount))) return '押金格式不正确'
|
||||||
|
if (pricing.recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < pricing.recommendedDepositAmount.value) {
|
||||||
|
return `押金不能低于智能推荐 ¥${pricing.recommendedDepositAmount.value}`
|
||||||
|
}
|
||||||
|
if (pricing.calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= pricing.calculatedConsumablePrice.value) {
|
||||||
|
return `押金必须大于额外消耗品总价值 ¥${pricing.calculatedConsumablePrice.value}`
|
||||||
|
}
|
||||||
|
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
|
||||||
|
if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
|
||||||
|
for (const item of pricing.screenshotSlots.value) {
|
||||||
|
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}`
|
||||||
|
}
|
||||||
|
for (const item of pricing.quantityItems.value) {
|
||||||
|
if (pricing.isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) {
|
||||||
|
return '赛季保险选择 3*3 时不能填写 9格体验卡'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScreenshotRequired(item: { key: string; required: boolean }) {
|
||||||
|
return item.required || (item.key === 'tencentSecurity' && shouldRequireBanEvidence())
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRequireBanEvidence() {
|
||||||
|
return pricing.banEvidenceOptions.value.includes(form.ban_record)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssetSummary() {
|
||||||
|
return {
|
||||||
|
face_owner: form.face_owner,
|
||||||
|
secret_kd: form.secret_kd,
|
||||||
|
fire_level: Number(form.fire_level),
|
||||||
|
daily_loss_m: pricing.dailyLossMAmount.value,
|
||||||
|
publish_ratio: pricing.calculatedPlatformPricing.value.buyerRatio,
|
||||||
|
price_breakdown: {
|
||||||
|
seller_reference_ratio: pricing.calculatedSellerReferenceRatio.value,
|
||||||
|
seller_ratio: pricing.calculatedRatio.value,
|
||||||
|
seller_coin_base_price: pricing.calculatedCoinBasePrice.value,
|
||||||
|
seller_total_price: pricing.calculatedSellerPrice.value,
|
||||||
|
consumable_price: pricing.calculatedConsumablePrice.value,
|
||||||
|
daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment.value,
|
||||||
|
accelerated_sale_ratio: pricing.hasAcceleratedSaleRatioInput()
|
||||||
|
? Number(form.accelerated_sale_ratio)
|
||||||
|
: pricing.calculatedDefaultSaleRatio.value,
|
||||||
|
buyer_coin_base_price: pricing.calculatedPlatformPricing.value.buyerCoinBasePrice,
|
||||||
|
buyer_total_price: pricing.calculatedFinalPrice.value,
|
||||||
|
buyer_ratio: pricing.calculatedPlatformPricing.value.buyerRatio,
|
||||||
|
platform_markup_amount: pricing.calculatedPlatformPricing.value.platformMarkupAmount,
|
||||||
|
platform_rule_type: pricing.calculatedPlatformPricing.value.ruleType,
|
||||||
|
},
|
||||||
|
season_insurance: form.season_insurance,
|
||||||
|
stamina_level: form.stamina_level,
|
||||||
|
load_level: form.load_level,
|
||||||
|
resources: pricing.quantityItems.value.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
price: item.price,
|
||||||
|
quantity: pricing.isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
|
||||||
|
mode: pricing.isQuantityItemDisabled(item) ? '赠送' : quantityModes[item.key] || '收费',
|
||||||
|
})),
|
||||||
|
skin_groups: pricing.skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
|
||||||
|
groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin))
|
||||||
|
return groups
|
||||||
|
}, {}),
|
||||||
|
online_time: {
|
||||||
|
start: form.online_start,
|
||||||
|
end: form.online_end,
|
||||||
|
},
|
||||||
|
ban_record: form.ban_record,
|
||||||
|
common_regions: form.common_regions,
|
||||||
|
remark: form.remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pricing,
|
||||||
|
dailyLossOptions,
|
||||||
|
commonOnlineTimes,
|
||||||
|
formatNumber,
|
||||||
|
router,
|
||||||
|
loading,
|
||||||
|
uploading,
|
||||||
|
fileInput,
|
||||||
|
activeUploadKey,
|
||||||
|
form,
|
||||||
|
quantityValues,
|
||||||
|
quantityModes,
|
||||||
|
screenshotFiles,
|
||||||
|
screenshotPreviews,
|
||||||
|
selectedSkins,
|
||||||
|
uploadedScreenshotCount,
|
||||||
|
requiredScreenshotCount,
|
||||||
|
loadPublishOptions,
|
||||||
|
saveDraft,
|
||||||
|
handleSaveDraft,
|
||||||
|
resetDraftState,
|
||||||
|
handleResetDraft,
|
||||||
|
toggleSkin,
|
||||||
|
toggleRegion,
|
||||||
|
triggerUpload,
|
||||||
|
handleScreenshotUpload,
|
||||||
|
removeScreenshot,
|
||||||
|
getScreenshotPreviewURL,
|
||||||
|
handleFireLevelInput,
|
||||||
|
handleAcceleratedSaleRatioInput,
|
||||||
|
syncRecommendedDeposit,
|
||||||
|
useRecommendedDeposit,
|
||||||
|
clearForbiddenQuantityItems,
|
||||||
|
setQuantityMode,
|
||||||
|
clampAcceleratedSaleRatioInput,
|
||||||
|
useReferenceSaleRatio,
|
||||||
|
useMaxAcceleratedSaleRatio,
|
||||||
|
handleSubmit,
|
||||||
|
validateForm,
|
||||||
|
isScreenshotRequired,
|
||||||
|
shouldRequireBanEvidence,
|
||||||
|
buildAssetSummary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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,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 * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,828 +1,93 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
import { useRoute } from 'vue-router'
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { showDialog, showToast } from 'vant'
|
||||||
import { showDialog, showToast } from "vant";
|
|
||||||
|
|
||||||
import { fetchFileBlobByURL, uploadFile } from "@/api/files";
|
import { usePublishForm } from '@/composables/usePublishForm'
|
||||||
import {
|
|
||||||
emptyListingSalePriceConfig,
|
|
||||||
emptyListingPublishOptions,
|
|
||||||
fetchListingPublishOptions,
|
|
||||||
fetchListingSalePriceConfig,
|
|
||||||
type ChargeMode,
|
|
||||||
type ListingPublishOptions,
|
|
||||||
type PublishSalePriceConfig,
|
|
||||||
type QuantityKey,
|
|
||||||
type ScreenshotKey,
|
|
||||||
} from "@/api/listingOptions";
|
|
||||||
import { createListing } from "@/api/listings";
|
|
||||||
|
|
||||||
type PublishForm = {
|
const route = useRoute()
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PublishDraft {
|
const {
|
||||||
form: PublishForm;
|
commonOnlineTimes,
|
||||||
quantityValues: Record<QuantityKey, number>;
|
dailyLossOptions,
|
||||||
quantityModes: Record<QuantityKey, ChargeMode>;
|
formatNumber,
|
||||||
screenshotFiles: Record<ScreenshotKey, string>;
|
router,
|
||||||
selectedSkins: string[];
|
loading,
|
||||||
}
|
uploading,
|
||||||
|
fileInput,
|
||||||
const draftKey = "hfb.mobile.publish.draft";
|
activeUploadKey,
|
||||||
const dailyLossOptions = [10, 20, 30, 40, 50];
|
form,
|
||||||
const router = useRouter();
|
quantityValues,
|
||||||
const route = useRoute();
|
quantityModes,
|
||||||
const loading = ref(false);
|
screenshotFiles,
|
||||||
const uploading = ref(false);
|
selectedSkins,
|
||||||
const suppressDraftSave = ref(false);
|
serverOptions,
|
||||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
|
faceOptions,
|
||||||
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig);
|
rankOptions,
|
||||||
|
insuranceOptions,
|
||||||
|
levelOptions,
|
||||||
|
loginMethodOptions,
|
||||||
|
regionOptions,
|
||||||
|
banRecordOptions,
|
||||||
|
skinGroups,
|
||||||
|
quantityItems,
|
||||||
|
screenshotSlots,
|
||||||
|
priceConfig,
|
||||||
|
fireLevelPlaceholder,
|
||||||
|
coinMAmount,
|
||||||
|
dailyLossMAmount,
|
||||||
|
calculatedDefaultSaleRatio,
|
||||||
|
maxAcceleratedSaleRatio,
|
||||||
|
calculatedCoinBasePrice,
|
||||||
|
calculatedConsumablePrice,
|
||||||
|
calculatedSellerPrice,
|
||||||
|
calculatedFinalPrice,
|
||||||
|
calculatedRatioText,
|
||||||
|
calculatedDefaultSaleRatioText,
|
||||||
|
saleRatioRangeText,
|
||||||
|
acceleratedSaleRatioPlaceholder,
|
||||||
|
recommendedDepositAmount,
|
||||||
|
hasAcceleratedSaleRatioInput,
|
||||||
|
isQuantityItemDisabled,
|
||||||
|
handleResetDraft,
|
||||||
|
toggleSkin,
|
||||||
|
toggleRegion,
|
||||||
|
triggerUpload,
|
||||||
|
handleScreenshotUpload,
|
||||||
|
removeScreenshot,
|
||||||
|
getScreenshotPreviewURL,
|
||||||
|
handleFireLevelInput,
|
||||||
|
handleAcceleratedSaleRatioInput,
|
||||||
|
useRecommendedDeposit,
|
||||||
|
setQuantityMode,
|
||||||
|
clampAcceleratedSaleRatioInput,
|
||||||
|
useReferenceSaleRatio,
|
||||||
|
useMaxAcceleratedSaleRatio,
|
||||||
|
handleSubmit,
|
||||||
|
isScreenshotRequired,
|
||||||
|
} = usePublishForm({
|
||||||
|
draftKey: 'hfb.mobile.publish.draft',
|
||||||
|
submitSuccessPath: '/m/profile',
|
||||||
|
async confirmReset() {
|
||||||
|
await showDialog({
|
||||||
|
title: '重置发布内容',
|
||||||
|
message: '将清空当前填写内容和本地草稿。',
|
||||||
|
confirmButtonText: '重置',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
showCancelButton: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
notifySuccess: (message) => showToast({ message, icon: 'passed' }),
|
||||||
|
notifyWarning: (message) => showToast({ message, icon: 'warning-o' }),
|
||||||
|
notifyError: (message) => showToast({ message, icon: 'cross' }),
|
||||||
|
})
|
||||||
|
|
||||||
function isNavActive(path: string) {
|
function isNavActive(path: string) {
|
||||||
if (path === "/m") return route.path === "/m";
|
if (path === '/m') return route.path === '/m'
|
||||||
return route.path.startsWith(path);
|
return route.path.startsWith(path)
|
||||||
}
|
|
||||||
|
|
||||||
function defaultForm(): PublishForm {
|
|
||||||
return {
|
|
||||||
server_region: "",
|
|
||||||
face_owner: "",
|
|
||||||
haf_coin_amount: "",
|
|
||||||
rank_level: "",
|
|
||||||
secret_kd: "",
|
|
||||||
fire_level: "",
|
|
||||||
daily_loss_m: 10,
|
|
||||||
accelerated_sale_ratio: "",
|
|
||||||
season_insurance: "",
|
|
||||||
stamina_level: "",
|
|
||||||
load_level: "",
|
|
||||||
login_method: "",
|
|
||||||
online_start: "",
|
|
||||||
online_end: "",
|
|
||||||
ban_record: "",
|
|
||||||
common_regions: [],
|
|
||||||
deposit_amount: "",
|
|
||||||
remark: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultQuantityValues(): Record<QuantityKey, number> {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultQuantityModes(): Record<QuantityKey, ChargeMode> {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultScreenshotFiles(): Record<ScreenshotKey, string> {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive<PublishForm>(defaultForm());
|
|
||||||
|
|
||||||
const quantityValues = reactive<Record<QuantityKey, number>>(defaultQuantityValues());
|
|
||||||
|
|
||||||
const quantityModes = reactive<Record<QuantityKey, ChargeMode>>(defaultQuantityModes());
|
|
||||||
|
|
||||||
const screenshotFiles = reactive<Record<ScreenshotKey, string>>(defaultScreenshotFiles());
|
|
||||||
const screenshotPreviews = reactive<Record<ScreenshotKey, string>>({});
|
|
||||||
|
|
||||||
const selectedSkins = ref<string[]>([]);
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const activeUploadKey = ref<ScreenshotKey>("coin");
|
|
||||||
|
|
||||||
const serverOptions = computed(() => publishOptions.value.server_options);
|
|
||||||
const faceOptions = computed(() => publishOptions.value.face_options);
|
|
||||||
const rankOptions = computed(() => publishOptions.value.rank_options);
|
|
||||||
const insuranceOptions = computed(() => publishOptions.value.insurance_options);
|
|
||||||
const levelOptions = computed(() => publishOptions.value.level_options);
|
|
||||||
const loginMethodOptions = computed(
|
|
||||||
() => publishOptions.value.login_method_options
|
|
||||||
);
|
|
||||||
const regionOptions = computed(() => publishOptions.value.region_options);
|
|
||||||
const banRecordOptions = computed(() => publishOptions.value.ban_record_options);
|
|
||||||
const banEvidenceOptions = computed(() => publishOptions.value.ban_evidence_options);
|
|
||||||
const priceConfig = computed(() => publishOptions.value.price_config);
|
|
||||||
const depositRecommendConfig = computed(() => publishOptions.value.deposit_recommend_config);
|
|
||||||
const fireLevelMin = computed(() => publishOptions.value.fire_level_min || 38);
|
|
||||||
const fireLevelPlaceholder = computed(
|
|
||||||
() => `等级低于${fireLevelMin.value}级的号无法发布`
|
|
||||||
);
|
|
||||||
const skinGroups = computed(() => publishOptions.value.skin_groups);
|
|
||||||
const quantityItems = computed(() => publishOptions.value.quantity_items);
|
|
||||||
const screenshotSlots = computed(() => publishOptions.value.screenshot_slots);
|
|
||||||
const screenshotUrls = computed(() =>
|
|
||||||
screenshotSlots.value
|
|
||||||
.map((item) => screenshotFiles[item.key])
|
|
||||||
.filter((url): url is string => Boolean(url))
|
|
||||||
);
|
|
||||||
const coinMAmount = computed(() => Number(form.haf_coin_amount || 0));
|
|
||||||
const coinWanAmount = computed(() => coinMAmount.value * 100);
|
|
||||||
const dailyLossMAmount = computed(() => Number(form.daily_loss_m || 10));
|
|
||||||
const dailyLossRatioAdjustment = computed(() =>
|
|
||||||
Math.min(Math.max(Math.floor((dailyLossMAmount.value - 10) / 10), 0), 4)
|
|
||||||
);
|
|
||||||
const calculatedSellerReferenceRatio = computed(() => calculateSellerReferenceRatio());
|
|
||||||
const calculatedDefaultSaleRatio = computed(() => calculatedSellerReferenceRatio.value);
|
|
||||||
const maxAcceleratedSaleRatio = computed(() =>
|
|
||||||
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0
|
|
||||||
);
|
|
||||||
const calculatedRatio = computed(() => readFinalSaleRatio());
|
|
||||||
const calculatedCoinBasePrice = computed(() => {
|
|
||||||
if (calculatedRatio.value <= 0) return 0;
|
|
||||||
return roundMoney(coinWanAmount.value / calculatedRatio.value);
|
|
||||||
});
|
|
||||||
const acceleratedSaleRatioPlaceholder = computed(() => {
|
|
||||||
if (calculatedDefaultSaleRatio.value <= 0) return "填写资料后自动生成可设置范围";
|
|
||||||
return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}`;
|
|
||||||
});
|
|
||||||
const calculatedConsumablePrice = computed(() => calculateConsumablePrice());
|
|
||||||
const calculatedSellerPrice = computed(() =>
|
|
||||||
calculatedRatio.value > 0
|
|
||||||
? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
const calculatedPlatformPricing = computed(() => calculatePlatformPricing());
|
|
||||||
const calculatedFinalPrice = computed(() => calculatedPlatformPricing.value.buyerTotalPrice);
|
|
||||||
const recommendedDepositAmount = computed(() => calculateRecommendedDeposit());
|
|
||||||
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)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
restoreDraft();
|
|
||||||
loadPublishOptions();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
revokeAllScreenshotPreviews();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
|
|
||||||
() => {
|
|
||||||
saveDraft();
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(recommendedDepositAmount, () => {
|
|
||||||
syncRecommendedDeposit();
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[() => form.season_insurance, quantityItems],
|
|
||||||
() => {
|
|
||||||
clearForbiddenQuantityItems();
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
async function loadPublishOptions() {
|
|
||||||
try {
|
|
||||||
const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
|
|
||||||
fetchListingPublishOptions(),
|
|
||||||
fetchListingSalePriceConfig(),
|
|
||||||
]);
|
|
||||||
publishOptions.value = nextPublishOptions;
|
|
||||||
salePriceConfig.value = nextSalePriceConfig;
|
|
||||||
} catch {
|
|
||||||
publishOptions.value = emptyListingPublishOptions;
|
|
||||||
salePriceConfig.value = emptyListingSalePriceConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraft(): PublishDraft {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
...form,
|
|
||||||
common_regions: [...form.common_regions],
|
|
||||||
},
|
|
||||||
quantityValues: { ...quantityValues },
|
|
||||||
quantityModes: { ...quantityModes },
|
|
||||||
screenshotFiles: { ...screenshotFiles },
|
|
||||||
selectedSkins: [...selectedSkins.value],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDraft() {
|
|
||||||
if (suppressDraftSave.value) return;
|
|
||||||
localStorage.setItem(draftKey, JSON.stringify(buildDraft()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreDraft() {
|
|
||||||
const raw = localStorage.getItem(draftKey);
|
|
||||||
if (!raw) return;
|
|
||||||
try {
|
|
||||||
const draft = JSON.parse(raw) as Partial<PublishDraft>;
|
|
||||||
Object.assign(form, normalizeDraftForm(draft.form));
|
|
||||||
Object.assign(quantityValues, defaultQuantityValues(), draft.quantityValues || {});
|
|
||||||
Object.assign(quantityModes, defaultQuantityModes(), draft.quantityModes || {});
|
|
||||||
Object.assign(screenshotFiles, defaultScreenshotFiles(), draft.screenshotFiles || {});
|
|
||||||
selectedSkins.value = Array.isArray(draft.selectedSkins)
|
|
||||||
? draft.selectedSkins.filter((skin): skin is string => typeof skin === "string")
|
|
||||||
: [];
|
|
||||||
hydrateScreenshotPreviews();
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(draftKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDraftForm(value: unknown): PublishForm {
|
|
||||||
const next = defaultForm();
|
|
||||||
if (!isRecord(value)) return next;
|
|
||||||
|
|
||||||
for (const key of Object.keys(next) as Array<keyof PublishForm>) {
|
|
||||||
if (key === "common_regions") continue;
|
|
||||||
const draftValue = value[key];
|
|
||||||
if (draftValue !== undefined) {
|
|
||||||
next[key] = draftValue as never;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next.common_regions = Array.isArray(value.common_regions)
|
|
||||||
? value.common_regions.filter((region): region is string => typeof region === "string")
|
|
||||||
: [];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDraftState() {
|
|
||||||
Object.assign(form, defaultForm());
|
|
||||||
Object.assign(quantityValues, defaultQuantityValues());
|
|
||||||
Object.assign(quantityModes, defaultQuantityModes());
|
|
||||||
Object.assign(screenshotFiles, defaultScreenshotFiles());
|
|
||||||
revokeAllScreenshotPreviews();
|
|
||||||
selectedSkins.value = [];
|
|
||||||
activeUploadKey.value = "coin";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetDraft() {
|
|
||||||
try {
|
|
||||||
await showDialog({
|
|
||||||
title: "重置发布内容",
|
|
||||||
message: "将清空当前填写内容和本地草稿。",
|
|
||||||
confirmButtonText: "重置",
|
|
||||||
cancelButtonText: "取消",
|
|
||||||
showCancelButton: true,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
suppressDraftSave.value = true;
|
|
||||||
resetDraftState();
|
|
||||||
localStorage.removeItem(draftKey);
|
|
||||||
showToast({ message: "已重置", icon: "passed" });
|
|
||||||
window.setTimeout(() => {
|
|
||||||
suppressDraftSave.value = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
||||||
setter(value);
|
setter(value)
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSkin(skin: string) {
|
|
||||||
selectedSkins.value = selectedSkins.value.includes(skin)
|
|
||||||
? selectedSkins.value.filter((item) => item !== skin)
|
|
||||||
: [...selectedSkins.value, skin];
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRegion(region: string) {
|
|
||||||
form.common_regions = form.common_regions.includes(region)
|
|
||||||
? form.common_regions.filter((item) => item !== region)
|
|
||||||
: [...form.common_regions, region];
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerUpload(key: ScreenshotKey) {
|
|
||||||
activeUploadKey.value = key;
|
|
||||||
fileInput.value?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScreenshotUpload(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const key = activeUploadKey.value;
|
|
||||||
const previewURL = URL.createObjectURL(file);
|
|
||||||
setScreenshotPreview(key, previewURL);
|
|
||||||
uploading.value = true;
|
|
||||||
try {
|
|
||||||
const uploaded = await uploadFile(file, "listing");
|
|
||||||
screenshotFiles[key] = uploaded.url;
|
|
||||||
showToast({ message: "截图已上传", icon: "passed" });
|
|
||||||
} catch (error) {
|
|
||||||
revokeScreenshotPreview(key);
|
|
||||||
showToast({ message: readError(error, "截图上传失败"), icon: "cross" });
|
|
||||||
} finally {
|
|
||||||
uploading.value = false;
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeScreenshot(key: ScreenshotKey) {
|
|
||||||
screenshotFiles[key] = "";
|
|
||||||
revokeScreenshotPreview(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScreenshotPreviewURL(key: ScreenshotKey) {
|
|
||||||
return screenshotPreviews[key] || screenshotFiles[key] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setScreenshotPreview(key: ScreenshotKey, previewURL: string) {
|
|
||||||
revokeScreenshotPreview(key);
|
|
||||||
screenshotPreviews[key] = previewURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function revokeScreenshotPreview(key: ScreenshotKey) {
|
|
||||||
const previewURL = screenshotPreviews[key];
|
|
||||||
if (previewURL?.startsWith("blob:")) {
|
|
||||||
URL.revokeObjectURL(previewURL);
|
|
||||||
}
|
|
||||||
delete screenshotPreviews[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function revokeAllScreenshotPreviews() {
|
|
||||||
for (const key of Object.keys(screenshotPreviews)) {
|
|
||||||
revokeScreenshotPreview(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateScreenshotPreviews() {
|
|
||||||
for (const [key, fileURL] of Object.entries(screenshotFiles)) {
|
|
||||||
if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith("/api/files/object")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const blob = await fetchFileBlobByURL(fileURL);
|
|
||||||
setScreenshotPreview(key, URL.createObjectURL(blob));
|
|
||||||
} catch {
|
|
||||||
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFireLevelInput(value: string | number) {
|
|
||||||
if (value === "") {
|
|
||||||
form.fire_level = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const level = Number(value);
|
|
||||||
if (!Number.isFinite(level)) {
|
|
||||||
form.fire_level = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.fire_level = Math.trunc(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAcceleratedSaleRatioInput(value: string | number) {
|
|
||||||
if (value === "") {
|
|
||||||
form.accelerated_sale_ratio = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ratio = Number(value);
|
|
||||||
form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncRecommendedDeposit() {
|
|
||||||
const recommended = recommendedDepositAmount.value;
|
|
||||||
if (recommended <= 0) return;
|
|
||||||
const current = Number(form.deposit_amount);
|
|
||||||
if (form.deposit_amount === "" || !Number.isFinite(current) || current < recommended) {
|
|
||||||
form.deposit_amount = recommended;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateRecommendedDeposit() {
|
|
||||||
const baseAmount = Number(depositRecommendConfig.value.base_amount || 0);
|
|
||||||
const skinAmount = depositRecommendConfig.value.skin_group_rules.reduce((sum, rule) => {
|
|
||||||
const group = skinGroups.value.find((item) => item.key === rule.group_key);
|
|
||||||
if (!group) return sum;
|
|
||||||
const selectedCount = group.options.filter((skin) =>
|
|
||||||
selectedSkins.value.includes(skin)
|
|
||||||
).length;
|
|
||||||
return sum + selectedCount * Number(rule.amount_per_item || 0);
|
|
||||||
}, 0);
|
|
||||||
return roundMoney(baseAmount + skinAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useRecommendedDeposit() {
|
|
||||||
if (recommendedDepositAmount.value > 0) {
|
|
||||||
form.deposit_amount = recommendedDepositAmount.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGridCardQuantityItem(item: { key: string; label: string }) {
|
|
||||||
return item.key === "gridCard9" || item.label.includes("9格体验卡");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isQuantityItemDisabled(item: { key: string; label: string }) {
|
|
||||||
return form.season_insurance === "3*3" && isGridCardQuantityItem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearForbiddenQuantityItems() {
|
|
||||||
for (const item of quantityItems.value) {
|
|
||||||
if (!isQuantityItemDisabled(item)) continue;
|
|
||||||
quantityValues[item.key] = 0;
|
|
||||||
quantityModes[item.key] = "赠送";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) {
|
|
||||||
if (isQuantityItemDisabled(item)) return;
|
|
||||||
quantityModes[item.key] = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampAcceleratedSaleRatioInput() {
|
|
||||||
if (!hasAcceleratedSaleRatioInput() || calculatedDefaultSaleRatio.value <= 0) return;
|
|
||||||
const ratio = Number(form.accelerated_sale_ratio);
|
|
||||||
if (!Number.isFinite(ratio)) {
|
|
||||||
form.accelerated_sale_ratio = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const minRatio = calculatedDefaultSaleRatio.value;
|
|
||||||
const maxRatio = maxAcceleratedSaleRatio.value;
|
|
||||||
form.accelerated_sale_ratio = roundRatio(Math.min(Math.max(ratio, minRatio), maxRatio));
|
|
||||||
}
|
|
||||||
|
|
||||||
function useReferenceSaleRatio() {
|
|
||||||
form.accelerated_sale_ratio = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMaxAcceleratedSaleRatio() {
|
|
||||||
if (maxAcceleratedSaleRatio.value <= 0) return;
|
|
||||||
form.accelerated_sale_ratio = maxAcceleratedSaleRatio.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
const error = validateForm();
|
|
||||||
if (error) {
|
|
||||||
showToast({ message: error, icon: "warning-o" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const title = `${form.server_region} ${form.rank_level} ${form.haf_coin_amount}M哈夫币`;
|
|
||||||
const listingPrice = calculatedFinalPrice.value;
|
|
||||||
|
|
||||||
const listing = await createListing({
|
|
||||||
title,
|
|
||||||
description: form.remark,
|
|
||||||
server_region: form.server_region,
|
|
||||||
login_platform: form.login_method,
|
|
||||||
rank_level: form.rank_level,
|
|
||||||
haf_coin_amount: coinMAmount.value * 1000000,
|
|
||||||
asset_summary: buildAssetSummary(),
|
|
||||||
screenshot_urls: screenshotUrls.value,
|
|
||||||
price: listingPrice,
|
|
||||||
deposit_amount: Number(form.deposit_amount),
|
|
||||||
});
|
|
||||||
localStorage.removeItem(draftKey);
|
|
||||||
showToast({
|
|
||||||
message:
|
|
||||||
listing.status === "published" && listing.review_status === "approved"
|
|
||||||
? "发布成功,已上架"
|
|
||||||
: "发布成功,等待后台审核",
|
|
||||||
icon: "passed",
|
|
||||||
});
|
|
||||||
await router.push("/m/profile");
|
|
||||||
} catch (error) {
|
|
||||||
showToast({
|
|
||||||
message: readError(error, "发布失败,请确认已登录并完成实名认证"),
|
|
||||||
icon: "cross",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateForm() {
|
|
||||||
if (!form.server_region) return "请选择区服";
|
|
||||||
if (coinMAmount.value <= 0) return "请填写哈夫币/M";
|
|
||||||
if (!form.rank_level) return "请选择段位";
|
|
||||||
if (!form.fire_level) return "请填写烽火等级";
|
|
||||||
if (Number(form.fire_level) < fireLevelMin.value) {
|
|
||||||
return `烽火等级低于${fireLevelMin.value}级的号无法发布`;
|
|
||||||
}
|
|
||||||
if (!form.season_insurance) return "请选择赛季保险";
|
|
||||||
if (!form.stamina_level) return "请选择体力等级";
|
|
||||||
if (!form.load_level) return "请选择负重等级";
|
|
||||||
if (!dailyLossOptions.includes(dailyLossMAmount.value)) return "请选择每日损耗";
|
|
||||||
if (hasAcceleratedSaleRatioInput()) {
|
|
||||||
const ratio = Number(form.accelerated_sale_ratio);
|
|
||||||
if (!Number.isFinite(ratio) || ratio <= 0) return "加速出售比例格式不正确";
|
|
||||||
if (calculatedDefaultSaleRatio.value > 0 && ratio < calculatedDefaultSaleRatio.value) {
|
|
||||||
return `加速出售比例不能低于默认比例 1:${formatNumber(calculatedDefaultSaleRatio.value)}`;
|
|
||||||
}
|
|
||||||
if (maxAcceleratedSaleRatio.value > 0 && ratio > maxAcceleratedSaleRatio.value) {
|
|
||||||
return `加速出售比例不能超过 1:${formatNumber(maxAcceleratedSaleRatio.value)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (banRecordOptions.value.length && !form.ban_record) return "请选择封禁记录";
|
|
||||||
if (form.deposit_amount === "" || Number(form.deposit_amount) < 0) {
|
|
||||||
return "请填写押金";
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(Number(form.deposit_amount))) {
|
|
||||||
return "押金格式不正确";
|
|
||||||
}
|
|
||||||
if (recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < recommendedDepositAmount.value) {
|
|
||||||
return `押金不能低于智能推荐 ¥${recommendedDepositAmount.value}`;
|
|
||||||
}
|
|
||||||
if (calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= calculatedConsumablePrice.value) {
|
|
||||||
return `押金必须大于额外消耗品总价值 ¥${calculatedConsumablePrice.value}`;
|
|
||||||
}
|
|
||||||
if (!calculatedFinalPrice.value) {
|
|
||||||
return "请完善币数、保险、体力和负重后再发布";
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(calculatedFinalPrice.value)) {
|
|
||||||
return "发布价格计算异常,请检查填写内容";
|
|
||||||
}
|
|
||||||
for (const item of screenshotSlots.value) {
|
|
||||||
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) {
|
|
||||||
return `请上传${item.label}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of quantityItems.value) {
|
|
||||||
if (isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) {
|
|
||||||
return "赛季保险选择 3*3 时不能填写 9格体验卡";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isScreenshotRequired(item: { key: string; required: boolean }) {
|
|
||||||
return item.required || (item.key === "tencentSecurity" && shouldRequireBanEvidence());
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRequireBanEvidence() {
|
|
||||||
return banEvidenceOptions.value.includes(form.ban_record);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAssetSummary() {
|
|
||||||
return {
|
|
||||||
face_owner: form.face_owner,
|
|
||||||
secret_kd: form.secret_kd,
|
|
||||||
fire_level: Number(form.fire_level),
|
|
||||||
daily_loss_m: dailyLossMAmount.value,
|
|
||||||
publish_ratio: calculatedPlatformPricing.value.buyerRatio,
|
|
||||||
price_breakdown: {
|
|
||||||
seller_reference_ratio: calculatedSellerReferenceRatio.value,
|
|
||||||
seller_ratio: calculatedRatio.value,
|
|
||||||
seller_coin_base_price: calculatedCoinBasePrice.value,
|
|
||||||
seller_total_price: calculatedSellerPrice.value,
|
|
||||||
consumable_price: calculatedConsumablePrice.value,
|
|
||||||
daily_loss_ratio_adjustment: dailyLossRatioAdjustment.value,
|
|
||||||
accelerated_sale_ratio: hasAcceleratedSaleRatioInput() ? Number(form.accelerated_sale_ratio) : calculatedDefaultSaleRatio.value,
|
|
||||||
buyer_coin_base_price: calculatedPlatformPricing.value.buyerCoinBasePrice,
|
|
||||||
buyer_total_price: calculatedFinalPrice.value,
|
|
||||||
buyer_ratio: calculatedPlatformPricing.value.buyerRatio,
|
|
||||||
platform_markup_amount: calculatedPlatformPricing.value.platformMarkupAmount,
|
|
||||||
platform_rule_type: calculatedPlatformPricing.value.ruleType,
|
|
||||||
},
|
|
||||||
season_insurance: form.season_insurance,
|
|
||||||
stamina_level: form.stamina_level,
|
|
||||||
load_level: form.load_level,
|
|
||||||
resources: quantityItems.value.map((item) => ({
|
|
||||||
key: item.key,
|
|
||||||
label: item.label,
|
|
||||||
price: item.price,
|
|
||||||
quantity: isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
|
|
||||||
mode: isQuantityItemDisabled(item) ? "赠送" : quantityModes[item.key] || "收费",
|
|
||||||
})),
|
|
||||||
skin_groups: skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
|
|
||||||
groups[group.key] = group.options.filter((skin) =>
|
|
||||||
selectedSkins.value.includes(skin)
|
|
||||||
);
|
|
||||||
return groups;
|
|
||||||
}, {}),
|
|
||||||
online_time: {
|
|
||||||
start: form.online_start,
|
|
||||||
end: form.online_end,
|
|
||||||
},
|
|
||||||
ban_record: form.ban_record,
|
|
||||||
common_regions: form.common_regions,
|
|
||||||
remark: form.remark,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
|
||||||
return Math.round(value * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateConsumablePrice() {
|
|
||||||
const total = quantityItems.value.reduce((sum, item) => {
|
|
||||||
const quantity = Number(quantityValues[item.key] || 0);
|
|
||||||
const mode = quantityModes[item.key] || "收费";
|
|
||||||
if (isQuantityItemDisabled(item)) return sum;
|
|
||||||
if (quantity <= 0 || mode !== "收费") return sum;
|
|
||||||
return sum + quantity * readUnitPrice(item.price);
|
|
||||||
}, 0);
|
|
||||||
return roundMoney(total);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 calculateSellerReferenceRatio() {
|
|
||||||
if (
|
|
||||||
coinMAmount.value <= 0 ||
|
|
||||||
!form.season_insurance ||
|
|
||||||
!form.stamina_level ||
|
|
||||||
!form.load_level
|
|
||||||
) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const baseRatio = getInsuranceBaseRatio(publishOptions.value.ratio_config, form.season_insurance);
|
|
||||||
if (baseRatio <= 0) return 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
baseRatio +
|
|
||||||
calculateConfigPenalty(publishOptions.value.ratio_config) +
|
|
||||||
getCoinCorrection(publishOptions.value.ratio_config, coinMAmount.value) +
|
|
||||||
dailyLossRatioAdjustment.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readFinalSaleRatio() {
|
|
||||||
const defaultRatio = calculatedDefaultSaleRatio.value;
|
|
||||||
if (defaultRatio <= 0) return 0;
|
|
||||||
if (!hasAcceleratedSaleRatioInput()) return defaultRatio;
|
|
||||||
const ratio = Number(form.accelerated_sale_ratio);
|
|
||||||
if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio;
|
|
||||||
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAcceleratedSaleRatioInput() {
|
|
||||||
return form.accelerated_sale_ratio !== "" && form.accelerated_sale_ratio !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculatePlatformPricing() {
|
|
||||||
if (calculatedRatio.value <= 0 || calculatedCoinBasePrice.value <= 0) {
|
|
||||||
return emptyPlatformPricing();
|
|
||||||
}
|
|
||||||
const fixedRule = findSaleFixedMarkupRule();
|
|
||||||
if (fixedRule) {
|
|
||||||
return buildPlatformPricing(
|
|
||||||
roundMoney(calculatedCoinBasePrice.value + Number(fixedRule.markup_amount || 0)),
|
|
||||||
"fixed_markup",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratioRule = findSaleRatioAdjustmentRule();
|
|
||||||
const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0;
|
|
||||||
const buyerRatio = calculatedRatio.value - ratioSubtract;
|
|
||||||
if (buyerRatio > 0 && ratioRule) {
|
|
||||||
return buildPlatformPricing(roundMoney(coinWanAmount.value / buyerRatio), "ratio_subtract");
|
|
||||||
}
|
|
||||||
return buildPlatformPricing(calculatedCoinBasePrice.value, "none");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlatformPricing(buyerCoinBasePrice: number, ruleType: string) {
|
|
||||||
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + calculatedConsumablePrice.value);
|
|
||||||
return {
|
|
||||||
buyerCoinBasePrice,
|
|
||||||
buyerTotalPrice,
|
|
||||||
buyerRatio: calculateEffectiveRatio(buyerCoinBasePrice),
|
|
||||||
platformMarkupAmount: roundMoney(buyerTotalPrice - calculatedSellerPrice.value),
|
|
||||||
ruleType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyPlatformPricing() {
|
|
||||||
return {
|
|
||||||
buyerCoinBasePrice: 0,
|
|
||||||
buyerTotalPrice: 0,
|
|
||||||
buyerRatio: 0,
|
|
||||||
platformMarkupAmount: 0,
|
|
||||||
ruleType: "none",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSaleFixedMarkupRule() {
|
|
||||||
return [...salePriceConfig.value.fixed_markup_rules]
|
|
||||||
.sort((a, b) => a.min_m - b.min_m)
|
|
||||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { includeLastMax: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSaleRatioAdjustmentRule() {
|
|
||||||
return [...salePriceConfig.value.ratio_adjustment_rules]
|
|
||||||
.sort((a, b) => a.min_m - b.min_m)
|
|
||||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { excludeFirstMin: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCoinInSaleRange(
|
|
||||||
item: { min_m: number; max_m: number },
|
|
||||||
index: number,
|
|
||||||
rules: Array<{ min_m: number; max_m: 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.value > minM : coinMAmount.value >= minM;
|
|
||||||
const isLastRule = index === rules.length - 1;
|
|
||||||
const maxMatched = maxM <= 0 || coinMAmount.value < maxM || (options.includeLastMax && isLastRule && coinMAmount.value <= maxM);
|
|
||||||
return minMatched && maxMatched;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateEffectiveRatio(price: number) {
|
|
||||||
if (price <= 0) return 0;
|
|
||||||
return roundRatio(coinWanAmount.value / price);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInsuranceBaseRatio(config: { insurance_base_ratios: Array<{ insurance: string; ratio: number }> }, insurance: string) {
|
|
||||||
return config.insurance_base_ratios.find(
|
|
||||||
(item) => item.insurance === insurance
|
|
||||||
)?.ratio || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateConfigPenalty(config: { config_items: Array<{ kind: string; group_key?: string; missing_penalty: number }> }) {
|
|
||||||
return config.config_items.reduce((sum, item) => {
|
|
||||||
return isRatioConfigItemMatched(item) ? sum : sum + Number(item.missing_penalty || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRatioConfigItemMatched(item: { kind: string; group_key?: string }) {
|
|
||||||
if (item.kind === "skin_group") return hasSelectedSkinGroup(item.group_key || "");
|
|
||||||
if (item.kind === "max_stamina") return isMaxLevel(form.stamina_level);
|
|
||||||
if (item.kind === "max_load") return isMaxLevel(form.load_level);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSelectedSkinGroup(groupKey: string) {
|
|
||||||
const group = skinGroups.value.find((item) => item.key === groupKey);
|
|
||||||
if (!group) return false;
|
|
||||||
return group.options.some((skin) => selectedSkins.value.includes(skin));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMaxLevel(value: string) {
|
|
||||||
const currentLevel = readLevelNumber(value);
|
|
||||||
const maxLevel = Math.max(...levelOptions.value.map(readLevelNumber).filter(Boolean));
|
|
||||||
if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel;
|
|
||||||
return value === levelOptions.value[levelOptions.value.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLevelNumber(value: string) {
|
|
||||||
const match = value.match(/\d+/);
|
|
||||||
return match ? Number(match[0]) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCoinCorrection(config: { coin_corrections: Array<{ threshold_m: number; correction: number }> }, coinM: number) {
|
|
||||||
return [...config.coin_corrections]
|
|
||||||
.sort((a, b) => b.threshold_m - a.threshold_m)
|
|
||||||
.find((item) => coinM > item.threshold_m)?.correction || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number) {
|
|
||||||
return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundRatio(value: number) {
|
|
||||||
return Math.round(value * 10) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,793 +1,86 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Delete, DocumentChecked, Picture, RefreshRight, UploadFilled } from '@element-plus/icons-vue'
|
import { Delete, DocumentChecked, Picture, RefreshRight, UploadFilled } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
import { fetchFileBlobByURL, uploadFile } from '@/api/files'
|
import { usePublishForm } from '@/composables/usePublishForm'
|
||||||
import {
|
|
||||||
emptyListingPublishOptions,
|
|
||||||
emptyListingSalePriceConfig,
|
|
||||||
fetchListingPublishOptions,
|
|
||||||
fetchListingSalePriceConfig,
|
|
||||||
type ChargeMode,
|
|
||||||
type ListingPublishOptions,
|
|
||||||
type PublishSalePriceConfig,
|
|
||||||
type QuantityKey,
|
|
||||||
type ScreenshotKey,
|
|
||||||
} from '@/api/listingOptions'
|
|
||||||
import { createListing } from '@/api/listings'
|
|
||||||
|
|
||||||
type PublishForm = {
|
const {
|
||||||
server_region: string
|
commonOnlineTimes,
|
||||||
face_owner: string
|
dailyLossOptions,
|
||||||
haf_coin_amount: number | ''
|
formatNumber,
|
||||||
rank_level: string
|
loading,
|
||||||
secret_kd: string
|
uploading,
|
||||||
fire_level: number | ''
|
fileInput,
|
||||||
daily_loss_m: number | ''
|
activeUploadKey,
|
||||||
accelerated_sale_ratio: number | ''
|
form,
|
||||||
season_insurance: string
|
quantityValues,
|
||||||
stamina_level: string
|
quantityModes,
|
||||||
load_level: string
|
screenshotFiles,
|
||||||
login_method: string
|
selectedSkins,
|
||||||
online_start: string
|
uploadedScreenshotCount,
|
||||||
online_end: string
|
requiredScreenshotCount,
|
||||||
ban_record: string
|
serverOptions,
|
||||||
common_regions: string[]
|
faceOptions,
|
||||||
deposit_amount: number | ''
|
rankOptions,
|
||||||
remark: string
|
insuranceOptions,
|
||||||
}
|
levelOptions,
|
||||||
|
loginMethodOptions,
|
||||||
interface PublishDraft {
|
regionOptions,
|
||||||
form: PublishForm
|
banRecordOptions,
|
||||||
quantityValues: Record<QuantityKey, number>
|
skinGroups,
|
||||||
quantityModes: Record<QuantityKey, ChargeMode>
|
quantityItems,
|
||||||
screenshotFiles: Record<ScreenshotKey, string>
|
screenshotSlots,
|
||||||
selectedSkins: string[]
|
priceConfig,
|
||||||
}
|
fireLevelPlaceholder,
|
||||||
|
coinMAmount,
|
||||||
const draftKey = 'hfb.pc.publish.draft'
|
dailyLossMAmount,
|
||||||
const dailyLossOptions = [10, 20, 30, 40, 50]
|
calculatedDefaultSaleRatio,
|
||||||
const commonOnlineTimes = ['00:00', '08:00', '10:00', '12:00', '14:00', '18:00', '20:00', '22:00', '23:59']
|
maxAcceleratedSaleRatio,
|
||||||
const router = useRouter()
|
calculatedCoinBasePrice,
|
||||||
const loading = ref(false)
|
calculatedConsumablePrice,
|
||||||
const uploading = ref(false)
|
calculatedFinalPrice,
|
||||||
const suppressDraftSave = ref(false)
|
calculatedRatioText,
|
||||||
const draftReady = ref(false)
|
calculatedDefaultSaleRatioText,
|
||||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
saleRatioRangeText,
|
||||||
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
|
acceleratedSaleRatioPlaceholder,
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
recommendedDepositAmount,
|
||||||
const activeUploadKey = ref<ScreenshotKey>('coin')
|
depositBreakdownItems,
|
||||||
|
platformRuleLabel,
|
||||||
function defaultForm(): PublishForm {
|
publishTitle,
|
||||||
return {
|
hasAcceleratedSaleRatioInput,
|
||||||
server_region: '',
|
isQuantityItemDisabled,
|
||||||
face_owner: '',
|
handleSaveDraft,
|
||||||
haf_coin_amount: '',
|
handleResetDraft,
|
||||||
rank_level: '',
|
toggleSkin,
|
||||||
secret_kd: '',
|
toggleRegion,
|
||||||
fire_level: '',
|
triggerUpload,
|
||||||
daily_loss_m: 10,
|
handleScreenshotUpload,
|
||||||
accelerated_sale_ratio: '',
|
removeScreenshot,
|
||||||
season_insurance: '',
|
getScreenshotPreviewURL,
|
||||||
stamina_level: '',
|
handleFireLevelInput,
|
||||||
load_level: '',
|
handleAcceleratedSaleRatioInput,
|
||||||
login_method: '',
|
useRecommendedDeposit,
|
||||||
online_start: '',
|
setQuantityMode,
|
||||||
online_end: '',
|
clampAcceleratedSaleRatioInput,
|
||||||
ban_record: '',
|
useReferenceSaleRatio,
|
||||||
common_regions: [],
|
useMaxAcceleratedSaleRatio,
|
||||||
deposit_amount: '',
|
handleSubmit,
|
||||||
remark: '',
|
isScreenshotRequired,
|
||||||
}
|
} = usePublishForm({
|
||||||
}
|
draftKey: 'hfb.pc.publish.draft',
|
||||||
|
submitSuccessPath: '/seller/listings',
|
||||||
const form = reactive<PublishForm>(defaultForm())
|
persistBeforeUnload: true,
|
||||||
const quantityValues = reactive<Record<QuantityKey, number>>({})
|
async confirmReset() {
|
||||||
const quantityModes = reactive<Record<QuantityKey, ChargeMode>>({})
|
|
||||||
const screenshotFiles = reactive<Record<ScreenshotKey, string>>({})
|
|
||||||
const screenshotPreviews = reactive<Record<ScreenshotKey, string>>({})
|
|
||||||
const selectedSkins = ref<string[]>([])
|
|
||||||
|
|
||||||
const serverOptions = computed(() => publishOptions.value.server_options)
|
|
||||||
const faceOptions = computed(() => publishOptions.value.face_options)
|
|
||||||
const rankOptions = computed(() => publishOptions.value.rank_options)
|
|
||||||
const insuranceOptions = computed(() => publishOptions.value.insurance_options)
|
|
||||||
const levelOptions = computed(() => publishOptions.value.level_options)
|
|
||||||
const loginMethodOptions = computed(() => publishOptions.value.login_method_options)
|
|
||||||
const regionOptions = computed(() => publishOptions.value.region_options)
|
|
||||||
const banRecordOptions = computed(() => publishOptions.value.ban_record_options)
|
|
||||||
const banEvidenceOptions = computed(() => publishOptions.value.ban_evidence_options)
|
|
||||||
const skinGroups = computed(() => publishOptions.value.skin_groups)
|
|
||||||
const quantityItems = computed(() => publishOptions.value.quantity_items)
|
|
||||||
const screenshotSlots = computed(() => publishOptions.value.screenshot_slots)
|
|
||||||
const priceConfig = computed(() => publishOptions.value.price_config)
|
|
||||||
const depositRecommendConfig = computed(() => publishOptions.value.deposit_recommend_config)
|
|
||||||
const fireLevelMin = computed(() => publishOptions.value.fire_level_min || 38)
|
|
||||||
const fireLevelPlaceholder = computed(() => `等级低于${fireLevelMin.value}级的号无法发布`)
|
|
||||||
const screenshotUrls = computed(() =>
|
|
||||||
screenshotSlots.value.map((item) => screenshotFiles[item.key]).filter((url): url is string => Boolean(url)),
|
|
||||||
)
|
|
||||||
const coinMAmount = computed(() => Number(form.haf_coin_amount || 0))
|
|
||||||
const coinWanAmount = computed(() => coinMAmount.value * 100)
|
|
||||||
const dailyLossMAmount = computed(() => Number(form.daily_loss_m || 10))
|
|
||||||
const dailyLossRatioAdjustment = computed(() =>
|
|
||||||
Math.min(Math.max(Math.floor((dailyLossMAmount.value - 10) / 10), 0), 4),
|
|
||||||
)
|
|
||||||
const calculatedSellerReferenceRatio = computed(() => calculateSellerReferenceRatio())
|
|
||||||
const calculatedDefaultSaleRatio = computed(() => calculatedSellerReferenceRatio.value)
|
|
||||||
const maxAcceleratedSaleRatio = computed(() =>
|
|
||||||
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0,
|
|
||||||
)
|
|
||||||
const calculatedRatio = computed(() => readFinalSaleRatio())
|
|
||||||
const calculatedCoinBasePrice = computed(() => {
|
|
||||||
if (calculatedRatio.value <= 0) return 0
|
|
||||||
return roundMoney(coinWanAmount.value / calculatedRatio.value)
|
|
||||||
})
|
|
||||||
const calculatedConsumablePrice = computed(() => calculateConsumablePrice())
|
|
||||||
const calculatedSellerPrice = computed(() =>
|
|
||||||
calculatedRatio.value > 0 ? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value) : 0,
|
|
||||||
)
|
|
||||||
const calculatedPlatformPricing = computed(() => calculatePlatformPricing())
|
|
||||||
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 uploadedScreenshotCount = computed(() => screenshotUrls.value.length)
|
|
||||||
const requiredScreenshotCount = computed(() => screenshotSlots.value.filter((item) => isScreenshotRequired(item)).length)
|
|
||||||
const recommendedDepositAmount = computed(() => calculateRecommendedDeposit())
|
|
||||||
const depositBreakdownItems = computed(() => buildDepositBreakdownItems())
|
|
||||||
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 = [form.server_region, form.rank_level, coinMAmount.value ? `${coinMAmount.value}M哈夫币` : ''].filter(Boolean)
|
|
||||||
return parts.length ? parts.join(' ') : '待完善账号信息'
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
restoreDraft()
|
|
||||||
draftReady.value = true
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
loadPublishOptions()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
saveDraft({ force: true })
|
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
revokeAllScreenshotPreviews()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
|
|
||||||
() => {
|
|
||||||
saveDraft()
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(recommendedDepositAmount, () => {
|
|
||||||
syncRecommendedDeposit()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[() => form.season_insurance, quantityItems],
|
|
||||||
() => {
|
|
||||||
clearForbiddenQuantityItems()
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function loadPublishOptions() {
|
|
||||||
try {
|
|
||||||
const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
|
|
||||||
fetchListingPublishOptions(),
|
|
||||||
fetchListingSalePriceConfig(),
|
|
||||||
])
|
|
||||||
publishOptions.value = nextPublishOptions
|
|
||||||
salePriceConfig.value = nextSalePriceConfig
|
|
||||||
} catch {
|
|
||||||
publishOptions.value = emptyListingPublishOptions
|
|
||||||
salePriceConfig.value = emptyListingSalePriceConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraft(): PublishDraft {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
...form,
|
|
||||||
common_regions: [...form.common_regions],
|
|
||||||
},
|
|
||||||
quantityValues: { ...quantityValues },
|
|
||||||
quantityModes: { ...quantityModes },
|
|
||||||
screenshotFiles: { ...screenshotFiles },
|
|
||||||
selectedSkins: [...selectedSkins.value],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDraft(options: { force?: boolean } = {}) {
|
|
||||||
if (suppressDraftSave.value) return
|
|
||||||
if (!options.force && !draftReady.value) return
|
|
||||||
localStorage.setItem(draftKey, JSON.stringify(buildDraft()))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveDraft() {
|
|
||||||
saveDraft({ force: true })
|
|
||||||
ElMessage.success('草稿已保存')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBeforeUnload() {
|
|
||||||
saveDraft({ force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreDraft() {
|
|
||||||
const raw = localStorage.getItem(draftKey)
|
|
||||||
if (!raw) return
|
|
||||||
try {
|
|
||||||
const draft = JSON.parse(raw) as Partial<PublishDraft>
|
|
||||||
Object.assign(form, normalizeDraftForm(draft.form))
|
|
||||||
Object.assign(quantityValues, draft.quantityValues || {})
|
|
||||||
Object.assign(quantityModes, draft.quantityModes || {})
|
|
||||||
Object.assign(screenshotFiles, draft.screenshotFiles || {})
|
|
||||||
selectedSkins.value = Array.isArray(draft.selectedSkins)
|
|
||||||
? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string')
|
|
||||||
: []
|
|
||||||
hydrateScreenshotPreviews()
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(draftKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDraftForm(value: unknown): PublishForm {
|
|
||||||
const next = defaultForm()
|
|
||||||
if (!isRecord(value)) return next
|
|
||||||
for (const key of Object.keys(next) as Array<keyof PublishForm>) {
|
|
||||||
if (key === 'common_regions') continue
|
|
||||||
const draftValue = value[key]
|
|
||||||
if (draftValue !== undefined) next[key] = draftValue as never
|
|
||||||
}
|
|
||||||
next.common_regions = Array.isArray(value.common_regions)
|
|
||||||
? value.common_regions.filter((region): region is string => typeof region === 'string')
|
|
||||||
: []
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDraftState() {
|
|
||||||
Object.assign(form, defaultForm())
|
|
||||||
for (const key of Object.keys(quantityValues)) delete quantityValues[key]
|
|
||||||
for (const key of Object.keys(quantityModes)) delete quantityModes[key]
|
|
||||||
for (const key of Object.keys(screenshotFiles)) delete screenshotFiles[key]
|
|
||||||
revokeAllScreenshotPreviews()
|
|
||||||
selectedSkins.value = []
|
|
||||||
activeUploadKey.value = 'coin'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetDraft() {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('将清空当前填写内容和本地草稿。', '重置发布内容', {
|
await ElMessageBox.confirm('将清空当前填写内容和本地草稿。', '重置发布内容', {
|
||||||
confirmButtonText: '重置',
|
confirmButtonText: '重置',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
} catch {
|
},
|
||||||
return
|
notifySuccess: (message) => ElMessage.success(message),
|
||||||
}
|
notifyWarning: (message) => ElMessage.warning(message),
|
||||||
suppressDraftSave.value = true
|
notifyError: (message) => ElMessage.error(message),
|
||||||
resetDraftState()
|
})
|
||||||
localStorage.removeItem(draftKey)
|
|
||||||
ElMessage.success('已重置')
|
|
||||||
window.setTimeout(() => {
|
|
||||||
suppressDraftSave.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSkin(skin: string) {
|
|
||||||
selectedSkins.value = selectedSkins.value.includes(skin)
|
|
||||||
? selectedSkins.value.filter((item) => item !== skin)
|
|
||||||
: [...selectedSkins.value, skin]
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRegion(region: string) {
|
|
||||||
form.common_regions = form.common_regions.includes(region)
|
|
||||||
? form.common_regions.filter((item) => item !== region)
|
|
||||||
: [...form.common_regions, region]
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerUpload(key: ScreenshotKey) {
|
|
||||||
activeUploadKey.value = key
|
|
||||||
fileInput.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScreenshotUpload(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
const key = activeUploadKey.value
|
|
||||||
const previewURL = URL.createObjectURL(file)
|
|
||||||
setScreenshotPreview(key, previewURL)
|
|
||||||
uploading.value = true
|
|
||||||
try {
|
|
||||||
const uploaded = await uploadFile(file, 'listing')
|
|
||||||
screenshotFiles[key] = uploaded.url
|
|
||||||
ElMessage.success('截图已上传')
|
|
||||||
} catch (error) {
|
|
||||||
revokeScreenshotPreview(key)
|
|
||||||
ElMessage.error(readError(error, '截图上传失败'))
|
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeScreenshot(key: ScreenshotKey) {
|
|
||||||
screenshotFiles[key] = ''
|
|
||||||
revokeScreenshotPreview(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScreenshotPreviewURL(key: ScreenshotKey) {
|
|
||||||
return screenshotPreviews[key] || screenshotFiles[key] || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function setScreenshotPreview(key: ScreenshotKey, previewURL: string) {
|
|
||||||
revokeScreenshotPreview(key)
|
|
||||||
screenshotPreviews[key] = previewURL
|
|
||||||
}
|
|
||||||
|
|
||||||
function revokeScreenshotPreview(key: ScreenshotKey) {
|
|
||||||
const previewURL = screenshotPreviews[key]
|
|
||||||
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
|
|
||||||
delete screenshotPreviews[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
function revokeAllScreenshotPreviews() {
|
|
||||||
for (const key of Object.keys(screenshotPreviews)) revokeScreenshotPreview(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateScreenshotPreviews() {
|
|
||||||
for (const [key, fileURL] of Object.entries(screenshotFiles)) {
|
|
||||||
if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith('/api/files/object')) continue
|
|
||||||
try {
|
|
||||||
const blob = await fetchFileBlobByURL(fileURL)
|
|
||||||
setScreenshotPreview(key, URL.createObjectURL(blob))
|
|
||||||
} catch {
|
|
||||||
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFireLevelInput(value: string | number | undefined) {
|
|
||||||
if (value === '' || value === undefined) {
|
|
||||||
form.fire_level = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const level = Number(value)
|
|
||||||
form.fire_level = Number.isFinite(level) ? Math.trunc(level) : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAcceleratedSaleRatioInput(value: string | number | undefined) {
|
|
||||||
if (value === '' || value === undefined) {
|
|
||||||
form.accelerated_sale_ratio = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ratio = Number(value)
|
|
||||||
form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncRecommendedDeposit() {
|
|
||||||
const recommended = recommendedDepositAmount.value
|
|
||||||
if (recommended <= 0) return
|
|
||||||
const current = Number(form.deposit_amount)
|
|
||||||
if (form.deposit_amount === '' || !Number.isFinite(current) || current < recommended) {
|
|
||||||
form.deposit_amount = recommended
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateRecommendedDeposit() {
|
|
||||||
const baseAmount = Number(depositRecommendConfig.value.base_amount || 0)
|
|
||||||
const skinAmount = depositRecommendConfig.value.skin_group_rules.reduce((sum, rule) => {
|
|
||||||
const group = skinGroups.value.find((item) => item.key === rule.group_key)
|
|
||||||
if (!group) return sum
|
|
||||||
const selectedCount = group.options.filter((skin) => selectedSkins.value.includes(skin)).length
|
|
||||||
return sum + selectedCount * Number(rule.amount_per_item || 0)
|
|
||||||
}, 0)
|
|
||||||
return roundMoney(baseAmount + skinAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDepositBreakdownItems() {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: '基础押金',
|
|
||||||
amount: Number(depositRecommendConfig.value.base_amount || 0),
|
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
for (const rule of depositRecommendConfig.value.skin_group_rules) {
|
|
||||||
const group = skinGroups.value.find((item) => item.key === rule.group_key)
|
|
||||||
if (!group) continue
|
|
||||||
const count = group.options.filter((skin) => selectedSkins.value.includes(skin)).length
|
|
||||||
if (count <= 0) continue
|
|
||||||
items.push({
|
|
||||||
label: rule.label,
|
|
||||||
amount: Number(rule.amount_per_item || 0) * count,
|
|
||||||
count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
function useRecommendedDeposit() {
|
|
||||||
if (recommendedDepositAmount.value > 0) {
|
|
||||||
form.deposit_amount = recommendedDepositAmount.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGridCardQuantityItem(item: { key: string; label: string }) {
|
|
||||||
return item.key === 'gridCard9' || item.label.includes('9格体验卡')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isQuantityItemDisabled(item: { key: string; label: string }) {
|
|
||||||
return form.season_insurance === '3*3' && isGridCardQuantityItem(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearForbiddenQuantityItems() {
|
|
||||||
for (const item of quantityItems.value) {
|
|
||||||
if (!isQuantityItemDisabled(item)) continue
|
|
||||||
quantityValues[item.key] = 0
|
|
||||||
quantityModes[item.key] = '赠送'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) {
|
|
||||||
if (isQuantityItemDisabled(item)) return
|
|
||||||
quantityModes[item.key] = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampAcceleratedSaleRatioInput() {
|
|
||||||
if (!hasAcceleratedSaleRatioInput() || calculatedDefaultSaleRatio.value <= 0) return
|
|
||||||
const ratio = Number(form.accelerated_sale_ratio)
|
|
||||||
if (!Number.isFinite(ratio)) {
|
|
||||||
form.accelerated_sale_ratio = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form.accelerated_sale_ratio = roundRatio(
|
|
||||||
Math.min(Math.max(ratio, calculatedDefaultSaleRatio.value), maxAcceleratedSaleRatio.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useReferenceSaleRatio() {
|
|
||||||
form.accelerated_sale_ratio = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMaxAcceleratedSaleRatio() {
|
|
||||||
if (maxAcceleratedSaleRatio.value <= 0) return
|
|
||||||
form.accelerated_sale_ratio = maxAcceleratedSaleRatio.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
const error = validateForm()
|
|
||||||
if (error) {
|
|
||||||
ElMessage.warning(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const listing = await createListing({
|
|
||||||
title: publishTitle.value,
|
|
||||||
description: form.remark,
|
|
||||||
server_region: form.server_region,
|
|
||||||
login_platform: form.login_method,
|
|
||||||
rank_level: form.rank_level,
|
|
||||||
haf_coin_amount: coinMAmount.value * 1000000,
|
|
||||||
asset_summary: buildAssetSummary(),
|
|
||||||
screenshot_urls: screenshotUrls.value,
|
|
||||||
price: calculatedFinalPrice.value,
|
|
||||||
deposit_amount: Number(form.deposit_amount),
|
|
||||||
})
|
|
||||||
localStorage.removeItem(draftKey)
|
|
||||||
suppressDraftSave.value = true
|
|
||||||
ElMessage.success(
|
|
||||||
listing.status === 'published' && listing.review_status === 'approved'
|
|
||||||
? '发布成功,已上架'
|
|
||||||
: '发布成功,等待后台审核',
|
|
||||||
)
|
|
||||||
await router.push('/seller/listings')
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(readError(error, '发布失败,请确认已登录并完成实名认证'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateForm() {
|
|
||||||
if (!form.server_region) return '请选择区服'
|
|
||||||
if (coinMAmount.value <= 0) return '请填写哈夫币/M'
|
|
||||||
if (!form.rank_level) return '请选择段位'
|
|
||||||
if (!form.fire_level) return '请填写烽火等级'
|
|
||||||
if (Number(form.fire_level) < fireLevelMin.value) return `烽火等级低于${fireLevelMin.value}级的号无法发布`
|
|
||||||
if (!form.season_insurance) return '请选择赛季保险'
|
|
||||||
if (!form.stamina_level) return '请选择体力等级'
|
|
||||||
if (!form.load_level) return '请选择负重等级'
|
|
||||||
if (!dailyLossOptions.includes(dailyLossMAmount.value)) return '请选择每日损耗'
|
|
||||||
if (hasAcceleratedSaleRatioInput()) {
|
|
||||||
const ratio = Number(form.accelerated_sale_ratio)
|
|
||||||
if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确'
|
|
||||||
if (calculatedDefaultSaleRatio.value > 0 && ratio < calculatedDefaultSaleRatio.value) {
|
|
||||||
return `加速出售比例不能低于默认比例 1:${formatNumber(calculatedDefaultSaleRatio.value)}`
|
|
||||||
}
|
|
||||||
if (maxAcceleratedSaleRatio.value > 0 && ratio > maxAcceleratedSaleRatio.value) {
|
|
||||||
return `加速出售比例不能超过 1:${formatNumber(maxAcceleratedSaleRatio.value)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录'
|
|
||||||
if (form.deposit_amount === '' || Number(form.deposit_amount) < 0) return '请填写押金'
|
|
||||||
if (!Number.isFinite(Number(form.deposit_amount))) return '押金格式不正确'
|
|
||||||
if (recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < recommendedDepositAmount.value) {
|
|
||||||
return `押金不能低于智能推荐 ¥${recommendedDepositAmount.value}`
|
|
||||||
}
|
|
||||||
if (calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= calculatedConsumablePrice.value) {
|
|
||||||
return `押金必须大于额外消耗品总价值 ¥${calculatedConsumablePrice.value}`
|
|
||||||
}
|
|
||||||
if (!calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
|
|
||||||
if (!Number.isFinite(calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
|
|
||||||
for (const item of screenshotSlots.value) {
|
|
||||||
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}`
|
|
||||||
}
|
|
||||||
for (const item of quantityItems.value) {
|
|
||||||
if (isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) {
|
|
||||||
return '赛季保险选择 3*3 时不能填写 9格体验卡'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function isScreenshotRequired(item: { key: string; required: boolean }) {
|
|
||||||
return item.required || (item.key === 'tencentSecurity' && shouldRequireBanEvidence())
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRequireBanEvidence() {
|
|
||||||
return banEvidenceOptions.value.includes(form.ban_record)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAssetSummary() {
|
|
||||||
return {
|
|
||||||
face_owner: form.face_owner,
|
|
||||||
secret_kd: form.secret_kd,
|
|
||||||
fire_level: Number(form.fire_level),
|
|
||||||
daily_loss_m: dailyLossMAmount.value,
|
|
||||||
publish_ratio: calculatedPlatformPricing.value.buyerRatio,
|
|
||||||
price_breakdown: {
|
|
||||||
seller_reference_ratio: calculatedSellerReferenceRatio.value,
|
|
||||||
seller_ratio: calculatedRatio.value,
|
|
||||||
seller_coin_base_price: calculatedCoinBasePrice.value,
|
|
||||||
seller_total_price: calculatedSellerPrice.value,
|
|
||||||
consumable_price: calculatedConsumablePrice.value,
|
|
||||||
daily_loss_ratio_adjustment: dailyLossRatioAdjustment.value,
|
|
||||||
accelerated_sale_ratio: hasAcceleratedSaleRatioInput()
|
|
||||||
? Number(form.accelerated_sale_ratio)
|
|
||||||
: calculatedDefaultSaleRatio.value,
|
|
||||||
buyer_coin_base_price: calculatedPlatformPricing.value.buyerCoinBasePrice,
|
|
||||||
buyer_total_price: calculatedFinalPrice.value,
|
|
||||||
buyer_ratio: calculatedPlatformPricing.value.buyerRatio,
|
|
||||||
platform_markup_amount: calculatedPlatformPricing.value.platformMarkupAmount,
|
|
||||||
platform_rule_type: calculatedPlatformPricing.value.ruleType,
|
|
||||||
},
|
|
||||||
season_insurance: form.season_insurance,
|
|
||||||
stamina_level: form.stamina_level,
|
|
||||||
load_level: form.load_level,
|
|
||||||
resources: quantityItems.value.map((item) => ({
|
|
||||||
key: item.key,
|
|
||||||
label: item.label,
|
|
||||||
price: item.price,
|
|
||||||
quantity: isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
|
|
||||||
mode: isQuantityItemDisabled(item) ? '赠送' : quantityModes[item.key] || '收费',
|
|
||||||
})),
|
|
||||||
skin_groups: skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
|
|
||||||
groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin))
|
|
||||||
return groups
|
|
||||||
}, {}),
|
|
||||||
online_time: {
|
|
||||||
start: form.online_start,
|
|
||||||
end: form.online_end,
|
|
||||||
},
|
|
||||||
ban_record: form.ban_record,
|
|
||||||
common_regions: form.common_regions,
|
|
||||||
remark: form.remark,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
|
||||||
return Math.round(value * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateConsumablePrice() {
|
|
||||||
const total = quantityItems.value.reduce((sum, item) => {
|
|
||||||
const quantity = Number(quantityValues[item.key] || 0)
|
|
||||||
const mode = quantityModes[item.key] || '收费'
|
|
||||||
if (isQuantityItemDisabled(item)) return sum
|
|
||||||
if (quantity <= 0 || mode !== '收费') return sum
|
|
||||||
return sum + quantity * readUnitPrice(item.price)
|
|
||||||
}, 0)
|
|
||||||
return roundMoney(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 calculateSellerReferenceRatio() {
|
|
||||||
if (coinMAmount.value <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) return 0
|
|
||||||
const baseRatio = getInsuranceBaseRatio(publishOptions.value.ratio_config, form.season_insurance)
|
|
||||||
if (baseRatio <= 0) return 0
|
|
||||||
return (
|
|
||||||
baseRatio +
|
|
||||||
calculateConfigPenalty(publishOptions.value.ratio_config) +
|
|
||||||
getCoinCorrection(publishOptions.value.ratio_config, coinMAmount.value) +
|
|
||||||
dailyLossRatioAdjustment.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readFinalSaleRatio() {
|
|
||||||
const defaultRatio = calculatedDefaultSaleRatio.value
|
|
||||||
if (defaultRatio <= 0) return 0
|
|
||||||
if (!hasAcceleratedSaleRatioInput()) return defaultRatio
|
|
||||||
const ratio = Number(form.accelerated_sale_ratio)
|
|
||||||
if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio
|
|
||||||
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAcceleratedSaleRatioInput() {
|
|
||||||
return form.accelerated_sale_ratio !== '' && form.accelerated_sale_ratio !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculatePlatformPricing() {
|
|
||||||
if (calculatedRatio.value <= 0 || calculatedCoinBasePrice.value <= 0) return emptyPlatformPricing()
|
|
||||||
const fixedRule = findSaleFixedMarkupRule()
|
|
||||||
if (fixedRule) {
|
|
||||||
return buildPlatformPricing(roundMoney(calculatedCoinBasePrice.value + Number(fixedRule.markup_amount || 0)), 'fixed_markup')
|
|
||||||
}
|
|
||||||
const ratioRule = findSaleRatioAdjustmentRule()
|
|
||||||
const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0
|
|
||||||
const buyerRatio = calculatedRatio.value - ratioSubtract
|
|
||||||
if (buyerRatio > 0 && ratioRule) {
|
|
||||||
return buildPlatformPricing(roundMoney(coinWanAmount.value / buyerRatio), 'ratio_subtract')
|
|
||||||
}
|
|
||||||
return buildPlatformPricing(calculatedCoinBasePrice.value, 'none')
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlatformPricing(buyerCoinBasePrice: number, ruleType: string) {
|
|
||||||
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + calculatedConsumablePrice.value)
|
|
||||||
return {
|
|
||||||
buyerCoinBasePrice,
|
|
||||||
buyerTotalPrice,
|
|
||||||
buyerRatio: calculateEffectiveRatio(buyerCoinBasePrice),
|
|
||||||
platformMarkupAmount: roundMoney(buyerTotalPrice - calculatedSellerPrice.value),
|
|
||||||
ruleType,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyPlatformPricing() {
|
|
||||||
return {
|
|
||||||
buyerCoinBasePrice: 0,
|
|
||||||
buyerTotalPrice: 0,
|
|
||||||
buyerRatio: 0,
|
|
||||||
platformMarkupAmount: 0,
|
|
||||||
ruleType: 'none',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSaleFixedMarkupRule() {
|
|
||||||
return [...salePriceConfig.value.fixed_markup_rules]
|
|
||||||
.sort((a, b) => a.min_m - b.min_m)
|
|
||||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { includeLastMax: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSaleRatioAdjustmentRule() {
|
|
||||||
return [...salePriceConfig.value.ratio_adjustment_rules]
|
|
||||||
.sort((a, b) => a.min_m - b.min_m)
|
|
||||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { excludeFirstMin: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCoinInSaleRange(
|
|
||||||
item: { min_m: number; max_m: number },
|
|
||||||
index: number,
|
|
||||||
rules: Array<{ min_m: number; max_m: 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.value > minM : coinMAmount.value >= minM
|
|
||||||
const isLastRule = index === rules.length - 1
|
|
||||||
const maxMatched =
|
|
||||||
maxM <= 0 || coinMAmount.value < maxM || (options.includeLastMax && isLastRule && coinMAmount.value <= maxM)
|
|
||||||
return minMatched && maxMatched
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateEffectiveRatio(price: number) {
|
|
||||||
if (price <= 0) return 0
|
|
||||||
return roundRatio(coinWanAmount.value / price)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInsuranceBaseRatio(config: { insurance_base_ratios: Array<{ insurance: string; ratio: number }> }, insurance: string) {
|
|
||||||
return config.insurance_base_ratios.find((item) => item.insurance === insurance)?.ratio || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateConfigPenalty(config: { config_items: Array<{ kind: string; group_key?: string; missing_penalty: number }> }) {
|
|
||||||
return config.config_items.reduce((sum, item) => {
|
|
||||||
return isRatioConfigItemMatched(item) ? sum : sum + Number(item.missing_penalty || 0)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRatioConfigItemMatched(item: { kind: string; group_key?: string }) {
|
|
||||||
if (item.kind === 'skin_group') return hasSelectedSkinGroup(item.group_key || '')
|
|
||||||
if (item.kind === 'max_stamina') return isMaxLevel(form.stamina_level)
|
|
||||||
if (item.kind === 'max_load') return isMaxLevel(form.load_level)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSelectedSkinGroup(groupKey: string) {
|
|
||||||
const group = skinGroups.value.find((item) => item.key === groupKey)
|
|
||||||
if (!group) return false
|
|
||||||
return group.options.some((skin) => selectedSkins.value.includes(skin))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMaxLevel(value: string) {
|
|
||||||
const currentLevel = readLevelNumber(value)
|
|
||||||
const maxLevel = Math.max(...levelOptions.value.map(readLevelNumber).filter(Boolean))
|
|
||||||
if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel
|
|
||||||
return value === levelOptions.value[levelOptions.value.length - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLevelNumber(value: string) {
|
|
||||||
const match = value.match(/\d+/)
|
|
||||||
return match ? Number(match[0]) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCoinCorrection(config: { coin_corrections: Array<{ threshold_m: number; correction: number }> }, coinM: number) {
|
|
||||||
return [...config.coin_corrections].sort((a, b) => b.threshold_m - a.threshold_m).find((item) => coinM > item.threshold_m)?.correction || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number) {
|
|
||||||
return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundRatio(value: number) {
|
|
||||||
return Math.round(value * 10) / 10
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -30,4 +30,26 @@ export default defineConfig({
|
|||||||
"fn.221329.xyz"
|
"fn.221329.xyz"
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes("node_modules")) return;
|
||||||
|
if (id.includes("element-plus") || id.includes("@element-plus")) {
|
||||||
|
return "vendor-element-plus";
|
||||||
|
}
|
||||||
|
if (id.includes("vant") || id.includes("@vant")) {
|
||||||
|
return "vendor-vant";
|
||||||
|
}
|
||||||
|
if (id.includes("vue") || id.includes("pinia")) {
|
||||||
|
return "vendor-vue";
|
||||||
|
}
|
||||||
|
if (id.includes("axios")) {
|
||||||
|
return "vendor-axios";
|
||||||
|
}
|
||||||
|
return "vendor";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user