988 lines
34 KiB
TypeScript
988 lines
34 KiB
TypeScript
import { readError } from '@/shared/utils/error'
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import { fetchFileBlobByURL, uploadFile } from '@/shared/api/files'
|
|
import {
|
|
emptyListingPublishAgreements,
|
|
emptyListingPublishOptions,
|
|
emptyListingSalePriceConfig,
|
|
fetchListingPublishAgreements,
|
|
fetchListingPublishOptions,
|
|
fetchListingSalePriceConfig,
|
|
type ChargeMode,
|
|
type ListingPublishAgreements,
|
|
type ListingPublishOptions,
|
|
type PublishSalePriceConfig,
|
|
type ScreenshotKey,
|
|
} from '@/features/listings/api/listingOptions'
|
|
import {
|
|
createListing,
|
|
fetchSellerListing,
|
|
updateListing,
|
|
type Listing,
|
|
} from '@/features/listings/api/listings'
|
|
import { usePricingCalculator } from '@/shared/composables/usePricingCalculator'
|
|
import {
|
|
buildPublishDraft,
|
|
clearRecord,
|
|
defaultPublishForm,
|
|
readPublishDraft,
|
|
removePublishDraft,
|
|
writePublishDraft,
|
|
} from '@/features/seller/composables/usePublishDraft'
|
|
import type { PublishForm } from '@/shared/types/publish'
|
|
import { yuanToCent } from '@/shared/utils/money'
|
|
import { dailyLossOptions, formatNumber, roundRatio } from '@/shared/utils/pricing'
|
|
|
|
const draftSaveDelay = 400
|
|
const multiScreenshotLimit = 3
|
|
const multiScreenshotKeys = new Set(['tencentSecurity', 'skin'])
|
|
const coinUnitPerM = 1000000
|
|
|
|
interface UsePublishFormOptions {
|
|
draftKey: string
|
|
submitSuccessPath: string
|
|
listingGroupChatPathPrefix?: 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 route = useRoute()
|
|
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 publishAgreements = ref<ListingPublishAgreements>(emptyListingPublishAgreements)
|
|
const virtualAssetSaleAgreementChecked = ref(false)
|
|
const sellerAgreementChecked = ref(false)
|
|
const passwordAndDeviceConfirmed = ref(false)
|
|
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[]>([])
|
|
let draftSaveTimer: number | undefined
|
|
const editListingID = computed(() => Number(route.params.id || 0))
|
|
const isEditMode = computed(() => Number.isFinite(editListingID.value) && editListingID.value > 0)
|
|
const pageTitle = computed(() => (isEditMode.value ? '编辑账号' : '发布账号'))
|
|
const submitButtonText = computed(() => (isEditMode.value ? '提交修改' : '立即发布'))
|
|
const submitLoadingText = computed(() => (isEditMode.value ? '提交中...' : '发布中...'))
|
|
const showBanRecordRiskHint = false
|
|
|
|
const pricing = usePricingCalculator({
|
|
form,
|
|
publishOptions,
|
|
salePriceConfig,
|
|
quantityValues,
|
|
quantityModes,
|
|
screenshotFiles,
|
|
selectedSkins,
|
|
})
|
|
|
|
// ========== 上号方式互斥 ==========
|
|
// 微信区账号只能扫码登录:选择微信区后,「账号密码」不可选,默认固定为「扫码」。
|
|
const scanOnlyServers = ['微信']
|
|
const scanLoginMethod = '扫码'
|
|
const passwordLoginMethod = '账号密码'
|
|
|
|
const isScanOnlyServer = computed(() => scanOnlyServers.includes(form.server_region))
|
|
|
|
const effectiveLoginMethodOptions = computed(() => {
|
|
if (!isScanOnlyServer.value) return pricing.loginMethodOptions.value
|
|
return pricing.loginMethodOptions.value.filter(option => option !== passwordLoginMethod)
|
|
})
|
|
|
|
watch(
|
|
() => form.server_region,
|
|
server => {
|
|
if (!scanOnlyServers.includes(server)) return
|
|
if (form.login_method !== scanLoginMethod) {
|
|
form.login_method = scanLoginMethod
|
|
}
|
|
}
|
|
)
|
|
const uploadedScreenshotCount = computed(
|
|
() => pricing.screenshotSlots.value.filter(item => getScreenshotCount(item.key) > 0).length
|
|
)
|
|
const requiredScreenshotCount = ref(0)
|
|
const canPublishAfterAgreements = computed(
|
|
() =>
|
|
virtualAssetSaleAgreementChecked.value &&
|
|
sellerAgreementChecked.value &&
|
|
passwordAndDeviceConfirmed.value
|
|
)
|
|
onMounted(async () => {
|
|
if (!isEditMode.value) restoreDraft()
|
|
if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
|
|
await loadPublishOptions()
|
|
if (isEditMode.value) {
|
|
await loadEditingListing()
|
|
}
|
|
draftReady.value = true
|
|
})
|
|
|
|
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, nextPublishAgreements] = await Promise.all([
|
|
fetchListingPublishOptions(),
|
|
fetchListingSalePriceConfig(),
|
|
fetchListingPublishAgreements(),
|
|
])
|
|
publishOptions.value = nextPublishOptions
|
|
salePriceConfig.value = nextSalePriceConfig
|
|
publishAgreements.value = nextPublishAgreements
|
|
|
|
// 默认数字都是0
|
|
for (const item of nextPublishOptions.quantity_items) {
|
|
if (quantityValues[item.key] === undefined) {
|
|
quantityValues[item.key] = 0
|
|
}
|
|
}
|
|
} catch {
|
|
publishOptions.value = emptyListingPublishOptions
|
|
salePriceConfig.value = emptyListingSalePriceConfig
|
|
publishAgreements.value = emptyListingPublishAgreements
|
|
}
|
|
}
|
|
|
|
async function loadEditingListing() {
|
|
if (!isEditMode.value) return
|
|
loading.value = true
|
|
try {
|
|
const listing = await fetchSellerListing(editListingID.value)
|
|
if (listing.status === 'completed') {
|
|
options.notifyError('该商品订单已完成,不能再次编辑')
|
|
await router.push(options.submitSuccessPath)
|
|
return
|
|
}
|
|
applyListingToForm(listing)
|
|
} catch (error) {
|
|
options.notifyError(readError(error, '商品信息加载失败'))
|
|
await router.push(options.submitSuccessPath)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function saveDraft(saveOptions: { force?: boolean } = {}) {
|
|
if (isEditMode.value) return
|
|
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)
|
|
normalizeOnlineTime()
|
|
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 applyListingToForm(listing: Listing) {
|
|
const summary = recordValue(listing.asset_summary)
|
|
const onlineTime = recordValue(summary.online_time)
|
|
const screenshotGroups = recordValue(summary.screenshot_groups)
|
|
const skinGroups = recordValue(summary.skin_groups)
|
|
const breakdown = recordValue(summary.price_breakdown)
|
|
|
|
clearRecord(quantityValues)
|
|
clearRecord(quantityModes)
|
|
clearRecord(screenshotFiles)
|
|
revokeAllScreenshotPreviews()
|
|
|
|
Object.assign(form, {
|
|
server_region: listing.server_region || '',
|
|
face_owner: stringValue(summary.face_owner),
|
|
haf_coin_amount: listing.haf_coin_amount > 0 ? listing.haf_coin_amount / 1000000 : '',
|
|
rank_level: listing.rank_level || '',
|
|
secret_kd: stringValue(summary.secret_kd),
|
|
fire_level: numberOrEmpty(summary.fire_level),
|
|
daily_loss_m: numberOrDefault(summary.daily_loss_m, 10),
|
|
accelerated_sale_ratio: numberOrEmpty(breakdown.accelerated_sale_ratio),
|
|
season_insurance: stringValue(summary.season_insurance),
|
|
stamina_level: stringValue(summary.stamina_level),
|
|
load_level: stringValue(summary.load_level),
|
|
login_method: listing.login_platform || '',
|
|
online_start: stringValue(onlineTime.start),
|
|
online_end: stringValue(onlineTime.end),
|
|
can_change_game_name: stringValue(summary.can_change_game_name),
|
|
all_hero: stringValue(summary.all_hero),
|
|
ban_record: stringValue(summary.ban_record),
|
|
common_regions: stringArray(summary.common_regions),
|
|
depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '',
|
|
remark: listing.description || stringValue(summary.remark),
|
|
})
|
|
|
|
for (const item of publishOptions.value.quantity_items) {
|
|
quantityValues[item.key] = 0
|
|
}
|
|
for (const item of arrayValue(summary.resources)) {
|
|
const resource = recordValue(item)
|
|
const key = stringValue(resource.key)
|
|
if (!key) continue
|
|
quantityValues[key] = numberOrDefault(resource.quantity, 0)
|
|
const mode = stringValue(resource.mode)
|
|
quantityModes[key] = mode === '赠送' ? '赠送' : '收费'
|
|
}
|
|
|
|
for (const [key, urls] of Object.entries(screenshotGroups)) {
|
|
const cleaned = stringArray(urls)
|
|
if (cleaned.length) screenshotFiles[key] = cleaned
|
|
}
|
|
if (!Object.keys(screenshotFiles).length && listing.screenshot_urls.length) {
|
|
screenshotFiles.coin = [...listing.screenshot_urls]
|
|
}
|
|
|
|
selectedSkins.value = Object.values(skinGroups).flatMap(value => stringArray(value))
|
|
virtualAssetSaleAgreementChecked.value = true
|
|
sellerAgreementChecked.value = true
|
|
passwordAndDeviceConfirmed.value = true
|
|
hydrateScreenshotPreviews()
|
|
}
|
|
|
|
function resetDraftState() {
|
|
Object.assign(form, defaultPublishForm())
|
|
clearRecord(quantityValues)
|
|
clearRecord(quantityModes)
|
|
clearRecord(screenshotFiles)
|
|
revokeAllScreenshotPreviews()
|
|
selectedSkins.value = []
|
|
virtualAssetSaleAgreementChecked.value = false
|
|
sellerAgreementChecked.value = false
|
|
passwordAndDeviceConfirmed.value = false
|
|
activeUploadKey.value = 'coin'
|
|
|
|
// Also re-initialize quantityValues to 0
|
|
for (const item of publishOptions.value.quantity_items) {
|
|
quantityValues[item.key] = 0
|
|
}
|
|
}
|
|
|
|
function normalizeOnlineTime() {
|
|
if (form.online_start === '全天' || form.online_end === '全天') {
|
|
form.online_start = '00:00'
|
|
form.online_end = '23:59'
|
|
}
|
|
}
|
|
|
|
function isAllDayOnline() {
|
|
return form.online_start === '00:00' && form.online_end === '23:59'
|
|
}
|
|
|
|
function selectAllDayOnline() {
|
|
form.online_start = '00:00'
|
|
form.online_end = '23:59'
|
|
}
|
|
|
|
function handleOnlineStartChange(value: string | number | null | undefined) {
|
|
form.online_start = normalizeOnlineTimeValue(value)
|
|
}
|
|
|
|
function handleOnlineEndChange(value: string | number | null | undefined) {
|
|
form.online_end = normalizeOnlineTimeValue(value)
|
|
}
|
|
|
|
/** 结束时刻早于开始时刻时视为跨天(每日 start 至次日 end) */
|
|
function isCrossDayOnline() {
|
|
const start = parseOnlineTime(form.online_start)
|
|
const end = parseOnlineTime(form.online_end)
|
|
return start !== null && end !== null && end < start
|
|
}
|
|
|
|
function onlineTimeRangeHint() {
|
|
if (isAllDayOnline()) return ''
|
|
if (!isCrossDayOnline()) return ''
|
|
return `每日 ${form.online_start} 至次日 ${form.online_end}`
|
|
}
|
|
|
|
function validateOnlineTime() {
|
|
const start = parseOnlineTime(form.online_start)
|
|
const end = parseOnlineTime(form.online_end)
|
|
if (start === null) return '请选择在线开始时间'
|
|
if (end === null) return '请选择在线结束时间'
|
|
if (start === end) return '在线开始和结束时间不能相同(全天请点「全天」)'
|
|
return ''
|
|
}
|
|
|
|
async function handleResetDraft() {
|
|
if (isEditMode.value) {
|
|
await loadEditingListing()
|
|
options.notifySuccess('已恢复原商品信息')
|
|
return
|
|
}
|
|
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) {
|
|
if (!canAddScreenshot(key)) {
|
|
options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`)
|
|
return
|
|
}
|
|
activeUploadKey.value = key
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function handleScreenshotUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const files = Array.from(input.files || [])
|
|
if (!files.length) return
|
|
const key = activeUploadKey.value
|
|
const availableCount = getScreenshotLimit(key) - getScreenshotCount(key)
|
|
if (availableCount <= 0) {
|
|
options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`)
|
|
input.value = ''
|
|
return
|
|
}
|
|
const uploadFiles = files.slice(0, availableCount)
|
|
if (files.length > availableCount) {
|
|
options.notifyWarning(
|
|
`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张,本次仅上传${availableCount}张`
|
|
)
|
|
}
|
|
uploading.value = true
|
|
try {
|
|
for (const file of uploadFiles) {
|
|
const previewURL = URL.createObjectURL(file)
|
|
try {
|
|
const uploaded = await uploadFile(file, 'listing')
|
|
appendScreenshot(key, uploaded.url, previewURL)
|
|
} catch (error) {
|
|
URL.revokeObjectURL(previewURL)
|
|
throw error
|
|
}
|
|
}
|
|
options.notifySuccess(
|
|
uploadFiles.length > 1 ? `已上传${uploadFiles.length}张截图` : '截图已上传'
|
|
)
|
|
} catch (error) {
|
|
options.notifyError(readError(error, '截图上传失败'))
|
|
} finally {
|
|
uploading.value = false
|
|
input.value = ''
|
|
}
|
|
}
|
|
|
|
function removeScreenshot(key: ScreenshotKey, index = 0) {
|
|
revokeScreenshotPreview(key, index)
|
|
screenshotFiles[key]?.splice(index, 1)
|
|
if (!screenshotFiles[key]?.length) delete screenshotFiles[key]
|
|
}
|
|
|
|
function getScreenshotPreviewURL(key: ScreenshotKey, index = 0) {
|
|
return screenshotPreviews[key]?.[index] || screenshotFiles[key]?.[index] || ''
|
|
}
|
|
|
|
function appendScreenshot(key: ScreenshotKey, fileURL: string, previewURL: string) {
|
|
if (!screenshotFiles[key]) screenshotFiles[key] = []
|
|
if (!screenshotPreviews[key]) screenshotPreviews[key] = []
|
|
screenshotFiles[key].push(fileURL)
|
|
screenshotPreviews[key].push(previewURL)
|
|
}
|
|
|
|
function setScreenshotPreview(key: ScreenshotKey, index: number, previewURL: string) {
|
|
revokeScreenshotPreview(key, index)
|
|
if (!screenshotPreviews[key]) screenshotPreviews[key] = []
|
|
screenshotPreviews[key][index] = previewURL
|
|
}
|
|
|
|
function revokeScreenshotPreview(key: ScreenshotKey, index: number) {
|
|
const previewURL = screenshotPreviews[key]?.[index]
|
|
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
|
|
screenshotPreviews[key]?.splice(index, 1)
|
|
if (!screenshotPreviews[key]?.length) delete screenshotPreviews[key]
|
|
}
|
|
|
|
function revokeAllScreenshotPreviews() {
|
|
for (const [key, previews] of Object.entries(screenshotPreviews)) {
|
|
for (const previewURL of previews) {
|
|
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
|
|
}
|
|
delete screenshotPreviews[key]
|
|
}
|
|
}
|
|
|
|
async function hydrateScreenshotPreviews() {
|
|
for (const [key, fileURLs] of Object.entries(screenshotFiles)) {
|
|
for (const [index, fileURL] of fileURLs.entries()) {
|
|
if (
|
|
!fileURL ||
|
|
screenshotPreviews[key]?.[index] ||
|
|
!fileURL.startsWith('/api/files/object')
|
|
)
|
|
continue
|
|
try {
|
|
const blob = await fetchFileBlobByURL(fileURL)
|
|
setScreenshotPreview(key, index, 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.depositAmountYuan)
|
|
if (form.depositAmountYuan === '' || !Number.isFinite(current) || current < recommended) {
|
|
form.depositAmountYuan = recommended
|
|
}
|
|
}
|
|
|
|
function useRecommendedDeposit() {
|
|
if (pricing.recommendedDepositAmount.value > 0) {
|
|
form.depositAmountYuan = pricing.recommendedDepositAmount.value
|
|
}
|
|
}
|
|
|
|
// 失焦时实时校正:押金低于(或低于等于无效/为空)智能推荐时自动补到推荐值并提示。
|
|
function handleDepositBlur() {
|
|
const recommended = pricing.recommendedDepositAmount.value
|
|
if (recommended <= 0) return
|
|
const current = Number(form.depositAmountYuan)
|
|
if (form.depositAmountYuan === '' || !Number.isFinite(current) || current < recommended) {
|
|
form.depositAmountYuan = recommended
|
|
options.notifyWarning(`押金不能低于智能推荐 ¥${recommended},已自动调整`)
|
|
}
|
|
}
|
|
|
|
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() {
|
|
normalizeOnlineTime()
|
|
const error = validateForm()
|
|
if (error) {
|
|
options.notifyWarning(error)
|
|
return
|
|
}
|
|
loading.value = true
|
|
try {
|
|
const payload = {
|
|
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: toHafCoinAmount(pricing.coinMAmount.value),
|
|
asset_summary: buildAssetSummary(),
|
|
screenshot_urls: pricing.screenshotUrls.value,
|
|
price_cent: yuanToCent(pricing.calculatedFinalPrice.value),
|
|
deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)),
|
|
agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
|
|
agreed_seller_agreement: sellerAgreementChecked.value,
|
|
}
|
|
const listing = isEditMode.value
|
|
? await updateListing(editListingID.value, payload)
|
|
: await createListing(payload)
|
|
if (!isEditMode.value) removePublishDraft(options.draftKey)
|
|
suppressDraftSave.value = true
|
|
clearDraftSaveTimer()
|
|
options.notifySuccess(
|
|
listing.status === 'published' && listing.review_status === 'approved'
|
|
? isEditMode.value
|
|
? '修改成功,已上架'
|
|
: '发布成功,已上架'
|
|
: isEditMode.value
|
|
? '修改成功,等待后台审核'
|
|
: '发布成功,等待后台审核'
|
|
)
|
|
// 新建发布成功后,若已自动建立发布群,跳进该群让号主立即看到欢迎语+二维码
|
|
if (!isEditMode.value && listing.listing_group_conversation_id) {
|
|
const chatPathPrefix = options.listingGroupChatPathPrefix || '/messages/'
|
|
await router.push(`${chatPathPrefix}${listing.listing_group_conversation_id}`)
|
|
} else {
|
|
await router.push(options.submitSuccessPath)
|
|
}
|
|
} catch (error) {
|
|
options.notifyError(
|
|
readError(
|
|
error,
|
|
isEditMode.value ? '修改失败,请稍后重试' : '发布失败,请确认已登录并完成实名认证'
|
|
)
|
|
)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function validateForm() {
|
|
if (!form.server_region) return '请选择区服'
|
|
if (!Number.isFinite(pricing.coinMAmount.value)) return '哈夫币/M格式不正确'
|
|
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 '请选择每日损耗'
|
|
const onlineTimeError = validateOnlineTime()
|
|
if (onlineTimeError) return onlineTimeError
|
|
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.can_change_game_name) return '请选择是否可以修改游戏名'
|
|
if (!form.all_hero) return '请选择是否有全英雄'
|
|
if (form.depositAmountYuan === '' || Number(form.depositAmountYuan) < 0) return '请填写押金'
|
|
if (!Number.isFinite(Number(form.depositAmountYuan))) return '押金格式不正确'
|
|
if (
|
|
pricing.recommendedDepositAmount.value > 0 &&
|
|
Number(form.depositAmountYuan) < pricing.recommendedDepositAmount.value
|
|
) {
|
|
return `押金不能低于智能推荐 ¥${pricing.recommendedDepositAmount.value}`
|
|
}
|
|
if (
|
|
pricing.calculatedConsumablePrice.value > 0 &&
|
|
Number(form.depositAmountYuan) <= pricing.calculatedConsumablePrice.value
|
|
) {
|
|
return `押金必须大于额外消耗品总价值 ¥${pricing.calculatedConsumablePrice.value}`
|
|
}
|
|
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
|
|
if (!Number.isFinite(pricing.calculatedFinalPrice.value))
|
|
return '发布价格计算异常,请检查填写内容'
|
|
if (!virtualAssetSaleAgreementChecked.value || !sellerAgreementChecked.value) {
|
|
return '请先阅读并勾选两份发布协议'
|
|
}
|
|
if (!passwordAndDeviceConfirmed.value)
|
|
return '请勾选确认已知晓:完成后须改密并清除登录设备等事项'
|
|
for (const item of pricing.screenshotSlots.value) {
|
|
if (isScreenshotRequired(item) && getScreenshotCount(item.key) === 0)
|
|
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] || '收费',
|
|
}))
|
|
.filter(item => item.quantity > 0),
|
|
skin_groups: pricing.skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
|
|
groups[group.key] = group.options.filter(skin => selectedSkins.value.includes(skin))
|
|
return groups
|
|
}, {}),
|
|
screenshot_groups: buildScreenshotGroups(),
|
|
online_time: {
|
|
start: form.online_start,
|
|
end: form.online_end,
|
|
},
|
|
can_change_game_name: form.can_change_game_name,
|
|
all_hero: form.all_hero,
|
|
ban_record: form.ban_record,
|
|
common_regions: form.common_regions,
|
|
remark: form.remark,
|
|
}
|
|
}
|
|
|
|
function buildScreenshotGroups() {
|
|
return pricing.screenshotSlots.value.reduce<Record<string, string[]>>((groups, slot) => {
|
|
groups[slot.key] = getScreenshotURLs(slot.key)
|
|
return groups
|
|
}, {})
|
|
}
|
|
|
|
function getScreenshotURLs(key: ScreenshotKey) {
|
|
return (screenshotFiles[key] || []).filter(Boolean)
|
|
}
|
|
|
|
function getScreenshotCount(key: ScreenshotKey) {
|
|
return getScreenshotURLs(key).length
|
|
}
|
|
|
|
function getScreenshotLimit(key: ScreenshotKey) {
|
|
return multiScreenshotKeys.has(key) ? multiScreenshotLimit : 1
|
|
}
|
|
|
|
function isMultiScreenshotSlot(key: ScreenshotKey) {
|
|
return getScreenshotLimit(key) > 1
|
|
}
|
|
|
|
function canAddScreenshot(key: ScreenshotKey) {
|
|
return !uploading.value && getScreenshotCount(key) < getScreenshotLimit(key)
|
|
}
|
|
|
|
function getScreenshotLimitHint(key: ScreenshotKey) {
|
|
const limit = getScreenshotLimit(key)
|
|
if (limit <= 1) return ''
|
|
return `最多${limit}张,已上传${getScreenshotCount(key)}张`
|
|
}
|
|
|
|
function getScreenshotLabel(key: ScreenshotKey) {
|
|
return pricing.screenshotSlots.value.find(item => item.key === key)?.label || '该截图'
|
|
}
|
|
|
|
function toHafCoinAmount(coinM: number) {
|
|
return Math.round(coinM * coinUnitPerM)
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {}
|
|
}
|
|
|
|
function arrayValue(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : []
|
|
}
|
|
|
|
function stringValue(value: unknown) {
|
|
if (value === undefined || value === null) return ''
|
|
return String(value)
|
|
}
|
|
|
|
function stringArray(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.filter((item): item is string => typeof item === 'string' && Boolean(item))
|
|
: []
|
|
}
|
|
|
|
function numberOrEmpty(value: unknown): number | '' {
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : ''
|
|
}
|
|
|
|
function numberOrDefault(value: unknown, fallback: number) {
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed) ? parsed : fallback
|
|
}
|
|
|
|
return {
|
|
...pricing,
|
|
dailyLossOptions,
|
|
formatNumber,
|
|
router,
|
|
loading,
|
|
uploading,
|
|
publishAgreements,
|
|
virtualAssetSaleAgreementChecked,
|
|
sellerAgreementChecked,
|
|
passwordAndDeviceConfirmed,
|
|
canPublishAfterAgreements,
|
|
showBanRecordRiskHint,
|
|
isScanOnlyServer,
|
|
effectiveLoginMethodOptions,
|
|
fileInput,
|
|
activeUploadKey,
|
|
form,
|
|
quantityValues,
|
|
quantityModes,
|
|
screenshotFiles,
|
|
screenshotPreviews,
|
|
selectedSkins,
|
|
uploadedScreenshotCount,
|
|
requiredScreenshotCount,
|
|
isEditMode,
|
|
pageTitle,
|
|
submitButtonText,
|
|
submitLoadingText,
|
|
loadPublishOptions,
|
|
saveDraft,
|
|
handleSaveDraft,
|
|
resetDraftState,
|
|
handleResetDraft,
|
|
isAllDayOnline,
|
|
isCrossDayOnline,
|
|
onlineTimeRangeHint,
|
|
selectAllDayOnline,
|
|
handleOnlineStartChange,
|
|
handleOnlineEndChange,
|
|
toggleSkin,
|
|
toggleRegion,
|
|
triggerUpload,
|
|
handleScreenshotUpload,
|
|
removeScreenshot,
|
|
getScreenshotURLs,
|
|
getScreenshotCount,
|
|
getScreenshotPreviewURL,
|
|
getScreenshotLimit,
|
|
isMultiScreenshotSlot,
|
|
canAddScreenshot,
|
|
getScreenshotLimitHint,
|
|
handleFireLevelInput,
|
|
handleAcceleratedSaleRatioInput,
|
|
syncRecommendedDeposit,
|
|
useRecommendedDeposit,
|
|
handleDepositBlur,
|
|
clearForbiddenQuantityItems,
|
|
setQuantityMode,
|
|
clampAcceleratedSaleRatioInput,
|
|
useReferenceSaleRatio,
|
|
useMaxAcceleratedSaleRatio,
|
|
handleSubmit,
|
|
validateForm,
|
|
isScreenshotRequired,
|
|
shouldRequireBanEvidence,
|
|
buildAssetSummary,
|
|
}
|
|
}
|
|
|
|
function parseOnlineTime(value: string) {
|
|
const match = /^(\d{2}):(\d{2})$/.exec(value)
|
|
if (!match) return null
|
|
const hour = Number(match[1])
|
|
const minute = Number(match[2])
|
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
|
|
return hour * 60 + minute
|
|
}
|
|
|
|
function normalizeOnlineTimeValue(value: string | number | null | undefined) {
|
|
if (value === null || value === undefined) return ''
|
|
const raw = String(value).trim()
|
|
// 兼容原生 time 可能带秒:08:00:00 -> 08:00
|
|
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?/.exec(raw)
|
|
if (!match?.[1] || !match[2]) return raw
|
|
return `${match[1].padStart(2, '0')}:${match[2]}`
|
|
}
|