重构前端:抽离发布表单逻辑

This commit is contained in:
yml
2026-05-25 01:26:15 +08:00
parent a8dc96aa3b
commit 3cf236def6
9 changed files with 1343 additions and 1599 deletions
@@ -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,
}
}
+122
View File
@@ -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
}
+514
View File
@@ -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
}