重构前端:抽离发布表单逻辑
This commit is contained in:
@@ -1,828 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { showDialog, showToast } from "vant";
|
||||
import { useRoute } from 'vue-router'
|
||||
import { showDialog, showToast } from 'vant'
|
||||
|
||||
import { fetchFileBlobByURL, uploadFile } from "@/api/files";
|
||||
import {
|
||||
emptyListingSalePriceConfig,
|
||||
emptyListingPublishOptions,
|
||||
fetchListingPublishOptions,
|
||||
fetchListingSalePriceConfig,
|
||||
type ChargeMode,
|
||||
type ListingPublishOptions,
|
||||
type PublishSalePriceConfig,
|
||||
type QuantityKey,
|
||||
type ScreenshotKey,
|
||||
} from "@/api/listingOptions";
|
||||
import { createListing } from "@/api/listings";
|
||||
import { usePublishForm } from '@/composables/usePublishForm'
|
||||
|
||||
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;
|
||||
};
|
||||
const route = useRoute()
|
||||
|
||||
interface PublishDraft {
|
||||
form: PublishForm;
|
||||
quantityValues: Record<QuantityKey, number>;
|
||||
quantityModes: Record<QuantityKey, ChargeMode>;
|
||||
screenshotFiles: Record<ScreenshotKey, string>;
|
||||
selectedSkins: string[];
|
||||
}
|
||||
|
||||
const draftKey = "hfb.mobile.publish.draft";
|
||||
const dailyLossOptions = [10, 20, 30, 40, 50];
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const suppressDraftSave = ref(false);
|
||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
|
||||
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig);
|
||||
const {
|
||||
commonOnlineTimes,
|
||||
dailyLossOptions,
|
||||
formatNumber,
|
||||
router,
|
||||
loading,
|
||||
uploading,
|
||||
fileInput,
|
||||
activeUploadKey,
|
||||
form,
|
||||
quantityValues,
|
||||
quantityModes,
|
||||
screenshotFiles,
|
||||
selectedSkins,
|
||||
serverOptions,
|
||||
faceOptions,
|
||||
rankOptions,
|
||||
insuranceOptions,
|
||||
levelOptions,
|
||||
loginMethodOptions,
|
||||
regionOptions,
|
||||
banRecordOptions,
|
||||
skinGroups,
|
||||
quantityItems,
|
||||
screenshotSlots,
|
||||
priceConfig,
|
||||
fireLevelPlaceholder,
|
||||
coinMAmount,
|
||||
dailyLossMAmount,
|
||||
calculatedDefaultSaleRatio,
|
||||
maxAcceleratedSaleRatio,
|
||||
calculatedCoinBasePrice,
|
||||
calculatedConsumablePrice,
|
||||
calculatedSellerPrice,
|
||||
calculatedFinalPrice,
|
||||
calculatedRatioText,
|
||||
calculatedDefaultSaleRatioText,
|
||||
saleRatioRangeText,
|
||||
acceleratedSaleRatioPlaceholder,
|
||||
recommendedDepositAmount,
|
||||
hasAcceleratedSaleRatioInput,
|
||||
isQuantityItemDisabled,
|
||||
handleResetDraft,
|
||||
toggleSkin,
|
||||
toggleRegion,
|
||||
triggerUpload,
|
||||
handleScreenshotUpload,
|
||||
removeScreenshot,
|
||||
getScreenshotPreviewURL,
|
||||
handleFireLevelInput,
|
||||
handleAcceleratedSaleRatioInput,
|
||||
useRecommendedDeposit,
|
||||
setQuantityMode,
|
||||
clampAcceleratedSaleRatioInput,
|
||||
useReferenceSaleRatio,
|
||||
useMaxAcceleratedSaleRatio,
|
||||
handleSubmit,
|
||||
isScreenshotRequired,
|
||||
} = usePublishForm({
|
||||
draftKey: 'hfb.mobile.publish.draft',
|
||||
submitSuccessPath: '/m/profile',
|
||||
async confirmReset() {
|
||||
await showDialog({
|
||||
title: '重置发布内容',
|
||||
message: '将清空当前填写内容和本地草稿。',
|
||||
confirmButtonText: '重置',
|
||||
cancelButtonText: '取消',
|
||||
showCancelButton: true,
|
||||
})
|
||||
},
|
||||
notifySuccess: (message) => showToast({ message, icon: 'passed' }),
|
||||
notifyWarning: (message) => showToast({ message, icon: 'warning-o' }),
|
||||
notifyError: (message) => showToast({ message, icon: 'cross' }),
|
||||
})
|
||||
|
||||
function isNavActive(path: string) {
|
||||
if (path === "/m") return route.path === "/m";
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
|
||||
function defaultForm(): 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: "",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultQuantityValues(): Record<QuantityKey, number> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function defaultQuantityModes(): Record<QuantityKey, ChargeMode> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function defaultScreenshotFiles(): Record<ScreenshotKey, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
const form = reactive<PublishForm>(defaultForm());
|
||||
|
||||
const quantityValues = reactive<Record<QuantityKey, number>>(defaultQuantityValues());
|
||||
|
||||
const quantityModes = reactive<Record<QuantityKey, ChargeMode>>(defaultQuantityModes());
|
||||
|
||||
const screenshotFiles = reactive<Record<ScreenshotKey, string>>(defaultScreenshotFiles());
|
||||
const screenshotPreviews = reactive<Record<ScreenshotKey, string>>({});
|
||||
|
||||
const selectedSkins = ref<string[]>([]);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const activeUploadKey = ref<ScreenshotKey>("coin");
|
||||
|
||||
const serverOptions = computed(() => publishOptions.value.server_options);
|
||||
const faceOptions = computed(() => publishOptions.value.face_options);
|
||||
const rankOptions = computed(() => publishOptions.value.rank_options);
|
||||
const insuranceOptions = computed(() => publishOptions.value.insurance_options);
|
||||
const levelOptions = computed(() => publishOptions.value.level_options);
|
||||
const loginMethodOptions = computed(
|
||||
() => publishOptions.value.login_method_options
|
||||
);
|
||||
const regionOptions = computed(() => publishOptions.value.region_options);
|
||||
const banRecordOptions = computed(() => publishOptions.value.ban_record_options);
|
||||
const banEvidenceOptions = computed(() => publishOptions.value.ban_evidence_options);
|
||||
const priceConfig = computed(() => publishOptions.value.price_config);
|
||||
const depositRecommendConfig = computed(() => publishOptions.value.deposit_recommend_config);
|
||||
const fireLevelMin = computed(() => publishOptions.value.fire_level_min || 38);
|
||||
const fireLevelPlaceholder = computed(
|
||||
() => `等级低于${fireLevelMin.value}级的号无法发布`
|
||||
);
|
||||
const skinGroups = computed(() => publishOptions.value.skin_groups);
|
||||
const quantityItems = computed(() => publishOptions.value.quantity_items);
|
||||
const screenshotSlots = computed(() => publishOptions.value.screenshot_slots);
|
||||
const screenshotUrls = computed(() =>
|
||||
screenshotSlots.value
|
||||
.map((item) => screenshotFiles[item.key])
|
||||
.filter((url): url is string => Boolean(url))
|
||||
);
|
||||
const coinMAmount = computed(() => Number(form.haf_coin_amount || 0));
|
||||
const coinWanAmount = computed(() => coinMAmount.value * 100);
|
||||
const dailyLossMAmount = computed(() => Number(form.daily_loss_m || 10));
|
||||
const dailyLossRatioAdjustment = computed(() =>
|
||||
Math.min(Math.max(Math.floor((dailyLossMAmount.value - 10) / 10), 0), 4)
|
||||
);
|
||||
const calculatedSellerReferenceRatio = computed(() => calculateSellerReferenceRatio());
|
||||
const calculatedDefaultSaleRatio = computed(() => calculatedSellerReferenceRatio.value);
|
||||
const maxAcceleratedSaleRatio = computed(() =>
|
||||
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0
|
||||
);
|
||||
const calculatedRatio = computed(() => readFinalSaleRatio());
|
||||
const calculatedCoinBasePrice = computed(() => {
|
||||
if (calculatedRatio.value <= 0) return 0;
|
||||
return roundMoney(coinWanAmount.value / calculatedRatio.value);
|
||||
});
|
||||
const acceleratedSaleRatioPlaceholder = computed(() => {
|
||||
if (calculatedDefaultSaleRatio.value <= 0) return "填写资料后自动生成可设置范围";
|
||||
return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}`;
|
||||
});
|
||||
const calculatedConsumablePrice = computed(() => calculateConsumablePrice());
|
||||
const calculatedSellerPrice = computed(() =>
|
||||
calculatedRatio.value > 0
|
||||
? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value)
|
||||
: 0
|
||||
);
|
||||
const calculatedPlatformPricing = computed(() => calculatePlatformPricing());
|
||||
const calculatedFinalPrice = computed(() => calculatedPlatformPricing.value.buyerTotalPrice);
|
||||
const recommendedDepositAmount = computed(() => calculateRecommendedDeposit());
|
||||
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)}`;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
restoreDraft();
|
||||
loadPublishOptions();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllScreenshotPreviews();
|
||||
});
|
||||
|
||||
watch(
|
||||
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
|
||||
() => {
|
||||
saveDraft();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(recommendedDepositAmount, () => {
|
||||
syncRecommendedDeposit();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(
|
||||
[() => form.season_insurance, quantityItems],
|
||||
() => {
|
||||
clearForbiddenQuantityItems();
|
||||
},
|
||||
{ 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 buildDraft(): PublishDraft {
|
||||
return {
|
||||
form: {
|
||||
...form,
|
||||
common_regions: [...form.common_regions],
|
||||
},
|
||||
quantityValues: { ...quantityValues },
|
||||
quantityModes: { ...quantityModes },
|
||||
screenshotFiles: { ...screenshotFiles },
|
||||
selectedSkins: [...selectedSkins.value],
|
||||
};
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (suppressDraftSave.value) return;
|
||||
localStorage.setItem(draftKey, JSON.stringify(buildDraft()));
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft = JSON.parse(raw) as Partial<PublishDraft>;
|
||||
Object.assign(form, normalizeDraftForm(draft.form));
|
||||
Object.assign(quantityValues, defaultQuantityValues(), draft.quantityValues || {});
|
||||
Object.assign(quantityModes, defaultQuantityModes(), draft.quantityModes || {});
|
||||
Object.assign(screenshotFiles, defaultScreenshotFiles(), draft.screenshotFiles || {});
|
||||
selectedSkins.value = Array.isArray(draft.selectedSkins)
|
||||
? draft.selectedSkins.filter((skin): skin is string => typeof skin === "string")
|
||||
: [];
|
||||
hydrateScreenshotPreviews();
|
||||
} catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDraftForm(value: unknown): PublishForm {
|
||||
const next = defaultForm();
|
||||
if (!isRecord(value)) return next;
|
||||
|
||||
for (const key of Object.keys(next) as Array<keyof PublishForm>) {
|
||||
if (key === "common_regions") continue;
|
||||
const draftValue = value[key];
|
||||
if (draftValue !== undefined) {
|
||||
next[key] = draftValue as never;
|
||||
}
|
||||
}
|
||||
next.common_regions = Array.isArray(value.common_regions)
|
||||
? value.common_regions.filter((region): region is string => typeof region === "string")
|
||||
: [];
|
||||
return next;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function resetDraftState() {
|
||||
Object.assign(form, defaultForm());
|
||||
Object.assign(quantityValues, defaultQuantityValues());
|
||||
Object.assign(quantityModes, defaultQuantityModes());
|
||||
Object.assign(screenshotFiles, defaultScreenshotFiles());
|
||||
revokeAllScreenshotPreviews();
|
||||
selectedSkins.value = [];
|
||||
activeUploadKey.value = "coin";
|
||||
}
|
||||
|
||||
async function handleResetDraft() {
|
||||
try {
|
||||
await showDialog({
|
||||
title: "重置发布内容",
|
||||
message: "将清空当前填写内容和本地草稿。",
|
||||
confirmButtonText: "重置",
|
||||
cancelButtonText: "取消",
|
||||
showCancelButton: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
suppressDraftSave.value = true;
|
||||
resetDraftState();
|
||||
localStorage.removeItem(draftKey);
|
||||
showToast({ message: "已重置", icon: "passed" });
|
||||
window.setTimeout(() => {
|
||||
suppressDraftSave.value = false;
|
||||
});
|
||||
if (path === '/m') return route.path === '/m'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
||||
setter(value);
|
||||
}
|
||||
|
||||
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;
|
||||
showToast({ message: "截图已上传", icon: "passed" });
|
||||
} catch (error) {
|
||||
revokeScreenshotPreview(key);
|
||||
showToast({ message: readError(error, "截图上传失败"), icon: "cross" });
|
||||
} 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) {
|
||||
if (value === "") {
|
||||
form.fire_level = "";
|
||||
return;
|
||||
}
|
||||
const level = Number(value);
|
||||
if (!Number.isFinite(level)) {
|
||||
form.fire_level = "";
|
||||
return;
|
||||
}
|
||||
form.fire_level = Math.trunc(level);
|
||||
}
|
||||
|
||||
function handleAcceleratedSaleRatioInput(value: string | number) {
|
||||
if (value === "") {
|
||||
form.accelerated_sale_ratio = "";
|
||||
return;
|
||||
}
|
||||
const ratio = Number(value);
|
||||
form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : "";
|
||||
}
|
||||
|
||||
function syncRecommendedDeposit() {
|
||||
const recommended = 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 calculateRecommendedDeposit() {
|
||||
const baseAmount = Number(depositRecommendConfig.value.base_amount || 0);
|
||||
const skinAmount = depositRecommendConfig.value.skin_group_rules.reduce((sum, rule) => {
|
||||
const group = skinGroups.value.find((item) => item.key === rule.group_key);
|
||||
if (!group) return sum;
|
||||
const selectedCount = group.options.filter((skin) =>
|
||||
selectedSkins.value.includes(skin)
|
||||
).length;
|
||||
return sum + selectedCount * Number(rule.amount_per_item || 0);
|
||||
}, 0);
|
||||
return roundMoney(baseAmount + skinAmount);
|
||||
}
|
||||
|
||||
function useRecommendedDeposit() {
|
||||
if (recommendedDepositAmount.value > 0) {
|
||||
form.deposit_amount = recommendedDepositAmount.value;
|
||||
}
|
||||
}
|
||||
|
||||
function isGridCardQuantityItem(item: { key: string; label: string }) {
|
||||
return item.key === "gridCard9" || item.label.includes("9格体验卡");
|
||||
}
|
||||
|
||||
function isQuantityItemDisabled(item: { key: string; label: string }) {
|
||||
return form.season_insurance === "3*3" && isGridCardQuantityItem(item);
|
||||
}
|
||||
|
||||
function clearForbiddenQuantityItems() {
|
||||
for (const item of quantityItems.value) {
|
||||
if (!isQuantityItemDisabled(item)) continue;
|
||||
quantityValues[item.key] = 0;
|
||||
quantityModes[item.key] = "赠送";
|
||||
}
|
||||
}
|
||||
|
||||
function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) {
|
||||
if (isQuantityItemDisabled(item)) return;
|
||||
quantityModes[item.key] = mode;
|
||||
}
|
||||
|
||||
function clampAcceleratedSaleRatioInput() {
|
||||
if (!hasAcceleratedSaleRatioInput() || calculatedDefaultSaleRatio.value <= 0) return;
|
||||
const ratio = Number(form.accelerated_sale_ratio);
|
||||
if (!Number.isFinite(ratio)) {
|
||||
form.accelerated_sale_ratio = "";
|
||||
return;
|
||||
}
|
||||
const minRatio = calculatedDefaultSaleRatio.value;
|
||||
const maxRatio = maxAcceleratedSaleRatio.value;
|
||||
form.accelerated_sale_ratio = roundRatio(Math.min(Math.max(ratio, minRatio), maxRatio));
|
||||
}
|
||||
|
||||
function useReferenceSaleRatio() {
|
||||
form.accelerated_sale_ratio = "";
|
||||
}
|
||||
|
||||
function useMaxAcceleratedSaleRatio() {
|
||||
if (maxAcceleratedSaleRatio.value <= 0) return;
|
||||
form.accelerated_sale_ratio = maxAcceleratedSaleRatio.value;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const error = validateForm();
|
||||
if (error) {
|
||||
showToast({ message: error, icon: "warning-o" });
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const title = `${form.server_region} ${form.rank_level} ${form.haf_coin_amount}M哈夫币`;
|
||||
const listingPrice = calculatedFinalPrice.value;
|
||||
|
||||
const listing = await createListing({
|
||||
title,
|
||||
description: form.remark,
|
||||
server_region: form.server_region,
|
||||
login_platform: form.login_method,
|
||||
rank_level: form.rank_level,
|
||||
haf_coin_amount: coinMAmount.value * 1000000,
|
||||
asset_summary: buildAssetSummary(),
|
||||
screenshot_urls: screenshotUrls.value,
|
||||
price: listingPrice,
|
||||
deposit_amount: Number(form.deposit_amount),
|
||||
});
|
||||
localStorage.removeItem(draftKey);
|
||||
showToast({
|
||||
message:
|
||||
listing.status === "published" && listing.review_status === "approved"
|
||||
? "发布成功,已上架"
|
||||
: "发布成功,等待后台审核",
|
||||
icon: "passed",
|
||||
});
|
||||
await router.push("/m/profile");
|
||||
} catch (error) {
|
||||
showToast({
|
||||
message: readError(error, "发布失败,请确认已登录并完成实名认证"),
|
||||
icon: "cross",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.server_region) return "请选择区服";
|
||||
if (coinMAmount.value <= 0) return "请填写哈夫币/M";
|
||||
if (!form.rank_level) return "请选择段位";
|
||||
if (!form.fire_level) return "请填写烽火等级";
|
||||
if (Number(form.fire_level) < fireLevelMin.value) {
|
||||
return `烽火等级低于${fireLevelMin.value}级的号无法发布`;
|
||||
}
|
||||
if (!form.season_insurance) return "请选择赛季保险";
|
||||
if (!form.stamina_level) return "请选择体力等级";
|
||||
if (!form.load_level) return "请选择负重等级";
|
||||
if (!dailyLossOptions.includes(dailyLossMAmount.value)) return "请选择每日损耗";
|
||||
if (hasAcceleratedSaleRatioInput()) {
|
||||
const ratio = Number(form.accelerated_sale_ratio);
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return "加速出售比例格式不正确";
|
||||
if (calculatedDefaultSaleRatio.value > 0 && ratio < calculatedDefaultSaleRatio.value) {
|
||||
return `加速出售比例不能低于默认比例 1:${formatNumber(calculatedDefaultSaleRatio.value)}`;
|
||||
}
|
||||
if (maxAcceleratedSaleRatio.value > 0 && ratio > maxAcceleratedSaleRatio.value) {
|
||||
return `加速出售比例不能超过 1:${formatNumber(maxAcceleratedSaleRatio.value)}`;
|
||||
}
|
||||
}
|
||||
if (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 (recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < recommendedDepositAmount.value) {
|
||||
return `押金不能低于智能推荐 ¥${recommendedDepositAmount.value}`;
|
||||
}
|
||||
if (calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= calculatedConsumablePrice.value) {
|
||||
return `押金必须大于额外消耗品总价值 ¥${calculatedConsumablePrice.value}`;
|
||||
}
|
||||
if (!calculatedFinalPrice.value) {
|
||||
return "请完善币数、保险、体力和负重后再发布";
|
||||
}
|
||||
if (!Number.isFinite(calculatedFinalPrice.value)) {
|
||||
return "发布价格计算异常,请检查填写内容";
|
||||
}
|
||||
for (const item of screenshotSlots.value) {
|
||||
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) {
|
||||
return `请上传${item.label}`;
|
||||
}
|
||||
}
|
||||
for (const item of quantityItems.value) {
|
||||
if (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 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: dailyLossMAmount.value,
|
||||
publish_ratio: calculatedPlatformPricing.value.buyerRatio,
|
||||
price_breakdown: {
|
||||
seller_reference_ratio: calculatedSellerReferenceRatio.value,
|
||||
seller_ratio: calculatedRatio.value,
|
||||
seller_coin_base_price: calculatedCoinBasePrice.value,
|
||||
seller_total_price: calculatedSellerPrice.value,
|
||||
consumable_price: calculatedConsumablePrice.value,
|
||||
daily_loss_ratio_adjustment: dailyLossRatioAdjustment.value,
|
||||
accelerated_sale_ratio: hasAcceleratedSaleRatioInput() ? Number(form.accelerated_sale_ratio) : calculatedDefaultSaleRatio.value,
|
||||
buyer_coin_base_price: calculatedPlatformPricing.value.buyerCoinBasePrice,
|
||||
buyer_total_price: calculatedFinalPrice.value,
|
||||
buyer_ratio: calculatedPlatformPricing.value.buyerRatio,
|
||||
platform_markup_amount: calculatedPlatformPricing.value.platformMarkupAmount,
|
||||
platform_rule_type: calculatedPlatformPricing.value.ruleType,
|
||||
},
|
||||
season_insurance: form.season_insurance,
|
||||
stamina_level: form.stamina_level,
|
||||
load_level: form.load_level,
|
||||
resources: quantityItems.value.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
price: item.price,
|
||||
quantity: isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
|
||||
mode: isQuantityItemDisabled(item) ? "赠送" : quantityModes[item.key] || "收费",
|
||||
})),
|
||||
skin_groups: skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
|
||||
groups[group.key] = group.options.filter((skin) =>
|
||||
selectedSkins.value.includes(skin)
|
||||
);
|
||||
return groups;
|
||||
}, {}),
|
||||
online_time: {
|
||||
start: form.online_start,
|
||||
end: form.online_end,
|
||||
},
|
||||
ban_record: form.ban_record,
|
||||
common_regions: form.common_regions,
|
||||
remark: form.remark,
|
||||
};
|
||||
}
|
||||
|
||||
function roundMoney(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function calculateConsumablePrice() {
|
||||
const total = quantityItems.value.reduce((sum, item) => {
|
||||
const quantity = Number(quantityValues[item.key] || 0);
|
||||
const mode = quantityModes[item.key] || "收费";
|
||||
if (isQuantityItemDisabled(item)) return sum;
|
||||
if (quantity <= 0 || mode !== "收费") return sum;
|
||||
return sum + quantity * readUnitPrice(item.price);
|
||||
}, 0);
|
||||
return roundMoney(total);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function calculateSellerReferenceRatio() {
|
||||
if (
|
||||
coinMAmount.value <= 0 ||
|
||||
!form.season_insurance ||
|
||||
!form.stamina_level ||
|
||||
!form.load_level
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const baseRatio = getInsuranceBaseRatio(publishOptions.value.ratio_config, form.season_insurance);
|
||||
if (baseRatio <= 0) return 0;
|
||||
|
||||
return (
|
||||
baseRatio +
|
||||
calculateConfigPenalty(publishOptions.value.ratio_config) +
|
||||
getCoinCorrection(publishOptions.value.ratio_config, coinMAmount.value) +
|
||||
dailyLossRatioAdjustment.value
|
||||
);
|
||||
}
|
||||
|
||||
function readFinalSaleRatio() {
|
||||
const defaultRatio = calculatedDefaultSaleRatio.value;
|
||||
if (defaultRatio <= 0) return 0;
|
||||
if (!hasAcceleratedSaleRatioInput()) return defaultRatio;
|
||||
const ratio = Number(form.accelerated_sale_ratio);
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio;
|
||||
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio.value));
|
||||
}
|
||||
|
||||
function hasAcceleratedSaleRatioInput() {
|
||||
return form.accelerated_sale_ratio !== "" && form.accelerated_sale_ratio !== null;
|
||||
}
|
||||
|
||||
function calculatePlatformPricing() {
|
||||
if (calculatedRatio.value <= 0 || calculatedCoinBasePrice.value <= 0) {
|
||||
return emptyPlatformPricing();
|
||||
}
|
||||
const fixedRule = findSaleFixedMarkupRule();
|
||||
if (fixedRule) {
|
||||
return buildPlatformPricing(
|
||||
roundMoney(calculatedCoinBasePrice.value + Number(fixedRule.markup_amount || 0)),
|
||||
"fixed_markup",
|
||||
);
|
||||
}
|
||||
|
||||
const ratioRule = findSaleRatioAdjustmentRule();
|
||||
const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0;
|
||||
const buyerRatio = calculatedRatio.value - ratioSubtract;
|
||||
if (buyerRatio > 0 && ratioRule) {
|
||||
return buildPlatformPricing(roundMoney(coinWanAmount.value / buyerRatio), "ratio_subtract");
|
||||
}
|
||||
return buildPlatformPricing(calculatedCoinBasePrice.value, "none");
|
||||
}
|
||||
|
||||
function buildPlatformPricing(buyerCoinBasePrice: number, ruleType: string) {
|
||||
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + calculatedConsumablePrice.value);
|
||||
return {
|
||||
buyerCoinBasePrice,
|
||||
buyerTotalPrice,
|
||||
buyerRatio: calculateEffectiveRatio(buyerCoinBasePrice),
|
||||
platformMarkupAmount: roundMoney(buyerTotalPrice - calculatedSellerPrice.value),
|
||||
ruleType,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPlatformPricing() {
|
||||
return {
|
||||
buyerCoinBasePrice: 0,
|
||||
buyerTotalPrice: 0,
|
||||
buyerRatio: 0,
|
||||
platformMarkupAmount: 0,
|
||||
ruleType: "none",
|
||||
};
|
||||
}
|
||||
|
||||
function findSaleFixedMarkupRule() {
|
||||
return [...salePriceConfig.value.fixed_markup_rules]
|
||||
.sort((a, b) => a.min_m - b.min_m)
|
||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { includeLastMax: true }));
|
||||
}
|
||||
|
||||
function findSaleRatioAdjustmentRule() {
|
||||
return [...salePriceConfig.value.ratio_adjustment_rules]
|
||||
.sort((a, b) => a.min_m - b.min_m)
|
||||
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { excludeFirstMin: true }));
|
||||
}
|
||||
|
||||
function isCoinInSaleRange(
|
||||
item: { min_m: number; max_m: number },
|
||||
index: number,
|
||||
rules: Array<{ min_m: number; max_m: 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.value > minM : coinMAmount.value >= minM;
|
||||
const isLastRule = index === rules.length - 1;
|
||||
const maxMatched = maxM <= 0 || coinMAmount.value < maxM || (options.includeLastMax && isLastRule && coinMAmount.value <= maxM);
|
||||
return minMatched && maxMatched;
|
||||
}
|
||||
|
||||
function calculateEffectiveRatio(price: number) {
|
||||
if (price <= 0) return 0;
|
||||
return roundRatio(coinWanAmount.value / price);
|
||||
}
|
||||
|
||||
function getInsuranceBaseRatio(config: { insurance_base_ratios: Array<{ insurance: string; ratio: number }> }, insurance: string) {
|
||||
return config.insurance_base_ratios.find(
|
||||
(item) => item.insurance === insurance
|
||||
)?.ratio || 0;
|
||||
}
|
||||
|
||||
function calculateConfigPenalty(config: { config_items: Array<{ kind: string; group_key?: string; missing_penalty: number }> }) {
|
||||
return config.config_items.reduce((sum, item) => {
|
||||
return isRatioConfigItemMatched(item) ? sum : sum + Number(item.missing_penalty || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function isRatioConfigItemMatched(item: { kind: string; group_key?: string }) {
|
||||
if (item.kind === "skin_group") return hasSelectedSkinGroup(item.group_key || "");
|
||||
if (item.kind === "max_stamina") return isMaxLevel(form.stamina_level);
|
||||
if (item.kind === "max_load") return isMaxLevel(form.load_level);
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSelectedSkinGroup(groupKey: string) {
|
||||
const group = skinGroups.value.find((item) => item.key === groupKey);
|
||||
if (!group) return false;
|
||||
return group.options.some((skin) => selectedSkins.value.includes(skin));
|
||||
}
|
||||
|
||||
function isMaxLevel(value: string) {
|
||||
const currentLevel = readLevelNumber(value);
|
||||
const maxLevel = Math.max(...levelOptions.value.map(readLevelNumber).filter(Boolean));
|
||||
if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel;
|
||||
return value === levelOptions.value[levelOptions.value.length - 1];
|
||||
}
|
||||
|
||||
function readLevelNumber(value: string) {
|
||||
const match = value.match(/\d+/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
}
|
||||
|
||||
function getCoinCorrection(config: { coin_corrections: Array<{ threshold_m: number; correction: number }> }, coinM: number) {
|
||||
return [...config.coin_corrections]
|
||||
.sort((a, b) => b.threshold_m - a.threshold_m)
|
||||
.find((item) => coinM > item.threshold_m)?.correction || 0;
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}`;
|
||||
}
|
||||
|
||||
function roundRatio(value: number) {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
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;
|
||||
setter(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user