Files
hfb_sys/frontend/src/features/seller/composables/usePublishForm.ts
T

842 lines
28 KiB
TypeScript

import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { 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 } 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 '@/types/publish'
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
const draftSaveDelay = 400
const multiScreenshotLimit = 3
const multiScreenshotKeys = new Set(['tencentSecurity', 'skin'])
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 publishAgreements = ref<ListingPublishAgreements>(emptyListingPublishAgreements)
const virtualAssetSaleAgreementChecked = ref(false)
const sellerAgreementChecked = 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 pricing = usePricingCalculator({
form,
publishOptions,
salePriceConfig,
quantityValues,
quantityModes,
screenshotFiles,
selectedSkins,
})
const uploadedScreenshotCount = computed(
() => pricing.screenshotSlots.value.filter(item => getScreenshotCount(item.key) > 0).length
)
const requiredScreenshotCount = ref(0)
const canPublishAfterAgreements = computed(
() => virtualAssetSaleAgreementChecked.value && sellerAgreementChecked.value
)
const disabledOnlineStartOptions = computed(() =>
commonOnlineTimes.filter(time => !canUseOnlineStart(time))
)
const disabledOnlineEndOptions = computed(() =>
commonOnlineTimes.filter(time => !canUseOnlineEnd(time))
)
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, 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
}
}
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)
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 resetDraftState() {
Object.assign(form, defaultPublishForm())
clearRecord(quantityValues)
clearRecord(quantityModes)
clearRecord(screenshotFiles)
revokeAllScreenshotPreviews()
selectedSkins.value = []
virtualAssetSaleAgreementChecked.value = false
sellerAgreementChecked.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 isOnlineStartPresetActive(value: string) {
return !isAllDayOnline() && form.online_start === value
}
function isOnlineEndPresetActive(value: string) {
return !isAllDayOnline() && form.online_end === value
}
function selectAllDayOnline() {
form.online_start = '00:00'
form.online_end = '23:59'
}
function selectOnlineStart(value: string | number) {
const time = String(value)
if (!canUseOnlineStart(time)) {
options.notifyWarning('开始时间必须早于结束时间')
return
}
form.online_start = time
}
function selectOnlineEnd(value: string | number) {
const time = String(value)
if (!canUseOnlineEnd(time)) {
options.notifyWarning('结束时间必须晚于开始时间')
return
}
form.online_end = time
}
function handleOnlineStartChange(value: string | number | null | undefined) {
const time = normalizeOnlineTimeValue(value)
form.online_start = time
const start = parseOnlineTime(time)
if (start === null) return
const lastTime = parseOnlineTime('23:59')
if (lastTime !== null && start >= lastTime) {
form.online_start = ''
options.notifyWarning('开始时间必须早于结束时间')
return
}
const end = parseOnlineTime(form.online_end)
if (end !== null && start >= end) {
form.online_end = ''
options.notifyWarning('结束时间已清空,请选择晚于开始时间的结束时间')
}
}
function handleOnlineEndChange(value: string | number | null | undefined) {
const time = normalizeOnlineTimeValue(value)
form.online_end = time
const end = parseOnlineTime(time)
if (end === null) return
const firstTime = parseOnlineTime('00:00')
if (firstTime !== null && end <= firstTime) {
form.online_end = ''
options.notifyWarning('结束时间必须晚于开始时间')
return
}
const start = parseOnlineTime(form.online_start)
if (start !== null && end <= start) {
form.online_end = ''
options.notifyWarning('结束时间必须晚于开始时间')
}
}
function canUseOnlineStart(value: string) {
const start = parseOnlineTime(value)
const lastTime = parseOnlineTime('23:59')
if (start === null || lastTime === null || start >= lastTime) return false
const end = parseOnlineTime(form.online_end)
return end === null || start < end
}
function canUseOnlineEnd(value: string) {
const end = parseOnlineTime(value)
const firstTime = parseOnlineTime('00:00')
if (end === null || firstTime === null || end <= firstTime) return false
const start = parseOnlineTime(form.online_start)
return start === null || end > start
}
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 '在线开始和结束时间不能相同'
if (end < start) return '在线结束时间必须晚于开始时间'
return ''
}
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) {
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.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() {
normalizeOnlineTime()
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),
agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
agreed_seller_agreement: sellerAgreementChecked.value,
})
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 '请选择每日损耗'
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.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 '发布价格计算异常,请检查填写内容'
if (!canPublishAfterAgreements.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] || '收费',
})),
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,
},
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 || '该截图'
}
return {
...pricing,
dailyLossOptions,
commonOnlineTimes,
formatNumber,
router,
loading,
uploading,
publishAgreements,
virtualAssetSaleAgreementChecked,
sellerAgreementChecked,
canPublishAfterAgreements,
fileInput,
activeUploadKey,
form,
quantityValues,
quantityModes,
screenshotFiles,
screenshotPreviews,
selectedSkins,
uploadedScreenshotCount,
requiredScreenshotCount,
disabledOnlineStartOptions,
disabledOnlineEndOptions,
loadPublishOptions,
saveDraft,
handleSaveDraft,
resetDraftState,
handleResetDraft,
isAllDayOnline,
isOnlineStartPresetActive,
isOnlineEndPresetActive,
selectAllDayOnline,
selectOnlineStart,
selectOnlineEnd,
handleOnlineStartChange,
handleOnlineEndChange,
toggleSkin,
toggleRegion,
triggerUpload,
handleScreenshotUpload,
removeScreenshot,
getScreenshotURLs,
getScreenshotCount,
getScreenshotPreviewURL,
getScreenshotLimit,
isMultiScreenshotSlot,
canAddScreenshot,
getScreenshotLimitHint,
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
}
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 ''
return String(value)
}