From 3cf236def6aa2823c3d38e83c730f2f4d54f40ef Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 01:26:15 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=89=8D=E7=AB=AF=EF=BC=9A?= =?UTF-8?q?=E6=8A=BD=E7=A6=BB=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/adminAuth.ts | 5 +- .../src/composables/usePricingCalculator.ts | 195 ++++ frontend/src/composables/usePublishDraft.ts | 122 +++ frontend/src/composables/usePublishForm.ts | 514 ++++++++++ frontend/src/types/publish.ts | 44 + frontend/src/utils/pricing.ts | 288 ++++++ .../mobile/MobileSellerListingCreateView.vue | 897 ++---------------- .../views/seller/SellerListingCreateView.vue | 855 ++--------------- frontend/vite.config.ts | 22 + 9 files changed, 1343 insertions(+), 1599 deletions(-) create mode 100644 frontend/src/composables/usePricingCalculator.ts create mode 100644 frontend/src/composables/usePublishDraft.ts create mode 100644 frontend/src/composables/usePublishForm.ts create mode 100644 frontend/src/types/publish.ts create mode 100644 frontend/src/utils/pricing.ts diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts index 9c8bc9d..ffdf426 100644 --- a/frontend/src/api/adminAuth.ts +++ b/frontend/src/api/adminAuth.ts @@ -1,3 +1,5 @@ +import axios from 'axios' + import { apiClient } from './client' export interface AdminUser { @@ -61,7 +63,6 @@ export async function logoutAdmin() { export async function refreshAdminSession() { const refreshToken = localStorage.getItem('admin_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 }) return data.data as AdminTokenPair -} \ No newline at end of file +} diff --git a/frontend/src/composables/usePricingCalculator.ts b/frontend/src/composables/usePricingCalculator.ts new file mode 100644 index 0000000..caa3230 --- /dev/null +++ b/frontend/src/composables/usePricingCalculator.ts @@ -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 + salePriceConfig: Ref + form: PublishForm + quantityValues: Record + quantityModes: Record + screenshotFiles: Record + selectedSkins: Ref +}) { + 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 = { + 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, + } +} diff --git a/frontend/src/composables/usePublishDraft.ts b/frontend/src/composables/usePublishDraft.ts new file mode 100644 index 0000000..334c646 --- /dev/null +++ b/frontend/src/composables/usePublishDraft.ts @@ -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 + quantityModes: Record + screenshotFiles: Record + 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 + 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) { + 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) { + 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 = {} + 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 = {} + 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 = {} + 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 { + return typeof value === 'object' && value !== null +} diff --git a/frontend/src/composables/usePublishForm.ts b/frontend/src/composables/usePublishForm.ts new file mode 100644 index 0000000..da08d17 --- /dev/null +++ b/frontend/src/composables/usePublishForm.ts @@ -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 + 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([]) + + 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>((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 +} diff --git a/frontend/src/types/publish.ts b/frontend/src/types/publish.ts new file mode 100644 index 0000000..721d4ed --- /dev/null +++ b/frontend/src/types/publish.ts @@ -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 + quantityModes: Record + screenshotFiles: Record + 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 +} diff --git a/frontend/src/utils/pricing.ts b/frontend/src/utils/pricing.ts new file mode 100644 index 0000000..8839d5a --- /dev/null +++ b/frontend/src/utils/pricing.ts @@ -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 + quantityModes: Record + 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, insurance: string) { + return config.insurance_base_ratios.find((item) => item.insurance === insurance)?.ratio || 0 +} + +function calculateConfigPenalty( + config: Pick, + 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, coinM: number) { + return [...config.coin_corrections].sort((a, b) => b.threshold_m - a.threshold_m).find((item) => coinM > item.threshold_m)?.correction || 0 +} diff --git a/frontend/src/views/mobile/MobileSellerListingCreateView.vue b/frontend/src/views/mobile/MobileSellerListingCreateView.vue index 679654b..7266bd3 100644 --- a/frontend/src/views/mobile/MobileSellerListingCreateView.vue +++ b/frontend/src/views/mobile/MobileSellerListingCreateView.vue @@ -1,828 +1,93 @@ diff --git a/frontend/src/views/seller/SellerListingCreateView.vue b/frontend/src/views/seller/SellerListingCreateView.vue index 51d7b96..07b75b5 100644 --- a/frontend/src/views/seller/SellerListingCreateView.vue +++ b/frontend/src/views/seller/SellerListingCreateView.vue @@ -1,793 +1,86 @@