import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { fetchFileBlobByURL, uploadFile } from '@/shared/api/files' import { emptyListingPublishOptions, emptyListingSalePriceConfig, fetchListingPublishOptions, fetchListingSalePriceConfig, type ChargeMode, type ListingPublishOptions, type PublishSalePriceConfig, type ScreenshotKey, } from '@/features/listings/api/listingOptions' import { createListing } from '@/features/listings/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' const draftSaveDelay = 400 interface UsePublishFormOptions { draftKey: string submitSuccessPath: string persistBeforeUnload?: boolean confirmReset?: () => Promise 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(emptyListingPublishOptions) const salePriceConfig = ref(emptyListingSalePriceConfig) const fileInput = ref(null) const activeUploadKey = ref('coin') const form = reactive(defaultPublishForm()) const quantityValues = reactive>({}) const quantityModes = reactive>({}) const screenshotFiles = reactive>({}) const screenshotPreviews = reactive>({}) const selectedSkins = ref([]) let draftSaveTimer: number | undefined 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 // 默认数字都是0 for (const item of nextPublishOptions.quantity_items) { if (quantityValues[item.key] === undefined) { quantityValues[item.key] = 0 } } } catch { publishOptions.value = emptyListingPublishOptions salePriceConfig.value = emptyListingSalePriceConfig } } function saveDraft(saveOptions: { force?: boolean } = {}) { if (suppressDraftSave.value) { clearDraftSaveTimer() return } if (!saveOptions.force && !draftReady.value) return if (!saveOptions.force) { scheduleDraftSave() return } clearDraftSaveTimer() writeDraft() } function scheduleDraftSave() { clearDraftSaveTimer() draftSaveTimer = window.setTimeout(() => { draftSaveTimer = undefined writeDraft() }, draftSaveDelay) } function clearDraftSaveTimer() { if (draftSaveTimer === undefined) return window.clearTimeout(draftSaveTimer) draftSaveTimer = undefined } function writeDraft() { 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' // Also re-initialize quantityValues to 0 for (const item of publishOptions.value.quantity_items) { quantityValues[item.key] = 0 } } async function handleResetDraft() { try { await options.confirmReset?.() } catch { return } suppressDraftSave.value = true clearDraftSaveTimer() 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 clearDraftSaveTimer() 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)) { const val = quantityValues[item.key] if (val === undefined || val === null || (val as unknown) === '') { return `请填写${item.label}的数量` } if (Number(val) < 0) { 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>((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 }