Files
hfb_sys/frontend/src/views/mobile/MobileSellerListingCreateView.vue
T

1294 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { 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";
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;
};
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);
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 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 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 }
);
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;
});
}
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 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 dailyPrice = calculatedFinalPrice.value;
const hourlyPrice = Math.max(roundMoney(dailyPrice / 24), 0.01);
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: dailyPrice,
price_hourly: hourlyPrice,
price_daily: dailyPrice,
price_weekly: roundMoney(dailyPrice * 7),
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 (!calculatedFinalPrice.value) {
return "请完善币数、保险、体力和负重后再发布";
}
if (!Number.isFinite(calculatedFinalPrice.value)) {
return "发布价格计算异常,请检查填写内容";
}
for (const item of screenshotSlots.value) {
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) {
return `请上传${item.label}`;
}
}
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: Number(quantityValues[item.key] || 0),
mode: 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 (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;
}
</script>
<template>
<main class="mobile-publish">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>发布账号</h1>
<span class="header-spacer"></span>
</header>
<section class="form-body">
<div class="form-section">
<h2 class="section-title">基础资料</h2>
<van-field label="区服" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in serverOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.server_region === opt }"
@click="selectRadio(opt, (value) => (form.server_region = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field label="是否本人人脸" class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in faceOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.face_owner === opt }"
@click="selectRadio(opt, (value) => (form.face_owner = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field
v-model="form.haf_coin_amount"
label="哈夫币/M"
type="digit"
required
placeholder="只填写仓库右上角纯币数额,总资产不计算在内;100M 写 100"
class="publish-field"
/>
<van-field label="段位" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in rankOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.rank_level === opt }"
@click="selectRadio(opt, (value) => (form.rank_level = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field
v-model="form.secret_kd"
label="绝密KD"
type="number"
placeholder="填写绝密KD,如果数据对不上买家可以申请退款"
class="publish-field"
/>
<van-field
:model-value="form.fire_level"
label="烽火等级"
type="digit"
required
:placeholder="fireLevelPlaceholder"
class="publish-field"
@update:model-value="handleFireLevelInput"
/>
<van-field label="赛季保险" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in insuranceOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.season_insurance === opt }"
@click="selectRadio(opt, (value) => (form.season_insurance = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<div class="level-panel">
<div class="level-row">
<div class="level-label"><span>*</span>体力</div>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`stamina-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.stamina_level === opt }"
@click="selectRadio(opt, (value) => (form.stamina_level = value))"
>
{{ opt }}
</button>
</div>
</div>
<div class="level-row">
<div class="level-label"><span>*</span>负重</div>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`load-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.load_level === opt }"
@click="selectRadio(opt, (value) => (form.load_level = value))"
>
{{ opt }}
</button>
</div>
</div>
</div>
</div>
<div class="form-section">
<h2 class="section-title">额外消耗品</h2>
<div v-for="item in quantityItems" :key="item.key" class="quantity-row">
<div class="quantity-card">
<div class="quantity-meta">
<strong
class="backend-hint-target"
:class="{ 'has-hint': Boolean(item.placeholder) }"
:data-hint="item.placeholder"
:tabindex="item.placeholder ? 0 : -1"
>
<span>*</span>{{ item.label }}
</strong>
<small>{{ item.price }}</small>
</div>
<input
v-model="quantityValues[item.key]"
type="number"
inputmode="numeric"
min="0"
placeholder="0"
class="quantity-input"
/>
</div>
<div class="mode-toggle">
<button
type="button"
:class="{ active: quantityModes[item.key] === '赠送' }"
@click="quantityModes[item.key] = '赠送'"
>
赠送
</button>
<button
type="button"
:class="{ active: (quantityModes[item.key] || '收费') === '收费' }"
@click="quantityModes[item.key] = '收费'"
>
收费
</button>
</div>
</div>
</div>
<div class="form-section">
<h2 class="section-title">皮肤与交接</h2>
<div class="skin-groups">
<div v-for="group in skinGroups" :key="group.key" class="skin-group">
<div class="skin-group-title">
<span>{{ group.title }}</span>
<small>
{{
group.options.filter((skin) => selectedSkins.includes(skin))
.length
}}
</small>
</div>
<div class="multi-chip-group">
<button
v-for="skin in group.options"
:key="skin"
type="button"
class="radio-btn"
:class="{ active: selectedSkins.includes(skin) }"
@click="toggleSkin(skin)"
>
{{ skin }}
</button>
</div>
</div>
</div>
<van-field label="上号方式" class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in loginMethodOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.login_method === opt }"
@click="selectRadio(opt, (value) => (form.login_method = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<p class="field-hint">
优先推荐账密登录】,出售速度约为扫码的数倍两种方式安全性一致账密登录更便捷请自行衡量
</p>
<div class="time-row">
<van-field
v-model="form.online_start"
label="在线开始"
type="time"
class="publish-field time-field"
/>
<van-field
v-model="form.online_end"
label="在线结束"
type="time"
class="publish-field time-field"
/>
</div>
<p class="field-hint">
此在线时间指的是百分百能够联系上您的时间若是在此期间联系不上您导致无法上号会扣除您的部分订单金额或上架押金在线时长太短可能无法上架请预留充足时间用于扫码以及冻结人脸请谨慎填写
</p>
<van-field label="封禁记录" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in banRecordOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.ban_record === opt }"
@click="selectRadio(opt, (value) => (form.ban_record = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<div class="region-panel">
<div class="region-title">
<span>常用登录地区</span>
<small>已选 {{ form.common_regions.length }}</small>
</div>
<div class="region-grid">
<button
v-for="region in regionOptions"
:key="region"
type="button"
class="region-btn"
:class="{ active: form.common_regions.includes(region) }"
@click="toggleRegion(region)"
>
{{ region }}
</button>
</div>
</div>
<p class="field-hint">
推荐勾选账号常用登录地便于同地区玩家租用可大幅减少账号触发异地登录保护
</p>
</div>
<div class="form-section">
<h2 class="section-title">截图材料</h2>
<div class="upload-list">
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-line">
<div class="upload-meta">
<strong
class="upload-title"
:class="{ 'has-hint': Boolean(slot.hint) }"
:data-hint="slot.hint"
:tabindex="slot.hint ? 0 : -1"
>
{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span>
</strong>
<small>{{ slot.hint }}</small>
</div>
<div v-if="screenshotFiles[slot.key]" class="upload-preview">
<img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
<button type="button" @click="removeScreenshot(slot.key)">移除</button>
</div>
<button
v-else
type="button"
class="upload-add"
:disabled="uploading"
@click="triggerUpload(slot.key)"
>
<van-icon name="photograph" :size="22" color="#999" />
<span>{{ uploading && activeUploadKey === slot.key ? "上传中" : "上传" }}</span>
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
style="display: none"
@change="handleScreenshotUpload"
/>
</div>
<div class="form-section">
<h2 class="section-title">押金与价格</h2>
<van-field
v-model="form.deposit_amount"
label="押金"
type="number"
required
:placeholder="priceConfig.deposit_placeholder"
suffix="元"
class="publish-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.deposit_placeholder" tabindex="0">押金</span>
</template>
</van-field>
<van-field label="每日损耗" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="loss in dailyLossOptions"
:key="loss"
type="button"
class="radio-btn"
:class="{ active: dailyLossMAmount === loss }"
@click="form.daily_loss_m = loss"
>
{{ loss }}M/
</button>
</div>
</template>
</van-field>
<p class="field-hint">
比例调整在默认10M/每增加10M出租比例相应+1请根据实际需求合理设置感谢您的配合
</p>
<div class="ratio-panel">
<div class="ratio-reference">
<span>参考比例</span>
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
</div>
<p class="ratio-range">{{ saleRatioRangeText }}比例越高出租速度越快自行选择</p>
<div class="ratio-mode-grid">
<button
type="button"
class="ratio-mode-btn"
:class="{ active: !hasAcceleratedSaleRatioInput() }"
:disabled="calculatedDefaultSaleRatio <= 0"
@click="useReferenceSaleRatio"
>
<strong>参考比例</strong>
<span>
{{
calculatedDefaultSaleRatio > 0
? `1元=${formatNumber(calculatedDefaultSaleRatio)}万`
: "自动计算"
}}
</span>
</button>
<button
type="button"
class="ratio-mode-btn"
:class="{ active: hasAcceleratedSaleRatioInput() }"
:disabled="maxAcceleratedSaleRatio <= 0"
@click="useMaxAcceleratedSaleRatio"
>
<strong>加速比例</strong>
<span>
{{
maxAcceleratedSaleRatio > 0
? `1元=${formatNumber(calculatedDefaultSaleRatio)}~${formatNumber(maxAcceleratedSaleRatio)}万`
: "自动计算"
}}
</span>
</button>
</div>
<van-field
:model-value="form.accelerated_sale_ratio"
label="自定比例"
type="number"
:placeholder="acceleratedSaleRatioPlaceholder"
class="publish-field ratio-input-field"
@update:model-value="handleAcceleratedSaleRatioInput"
@blur="clampAcceleratedSaleRatioInput"
/>
<p class="field-hint ratio-input-hint">
留空使用参考比例填写后范围为参考比例到参考比例+10请根据实际需求合理设置
</p>
</div>
<div class="result-grid">
<van-field
:model-value="calculatedRatioText"
label="卖家比例"
readonly
:placeholder="priceConfig.ratio_description"
class="publish-field result-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.ratio_description" tabindex="0">卖家比例</span>
</template>
</van-field>
<van-field
:model-value="calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : ''"
label="纯币基础价"
readonly
required
:placeholder="priceConfig.price_placeholder"
class="publish-field result-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.price_placeholder" tabindex="0">纯币基础价</span>
</template>
</van-field>
<van-field
:model-value="`¥${calculatedConsumablePrice}`"
label="额外消耗品"
readonly
class="publish-field result-field"
/>
<van-field
:model-value="calculatedSellerPrice ? `¥${calculatedSellerPrice}` : ''"
label="卖家价格"
readonly
required
:placeholder="priceConfig.price_placeholder"
class="publish-field result-field total-price-field"
/>
</div>
<p class="price-breakdown-hint">
卖家价格 = 纯币基础价 + 额外消耗品加速比例仅影响纯币基础价
</p>
<van-field
v-model="form.remark"
label="备注"
type="textarea"
rows="3"
autosize
placeholder="如有一些不可使用的物资,请在此备注,并且在买家下单后,主动在群聊处,再次提醒买家"
class="publish-field"
/>
</div>
<div class="publish-actions">
<van-button
round
class="reset-btn"
:disabled="loading"
@click="handleResetDraft"
>
重置
</van-button>
<van-button
type="primary"
round
class="submit-btn"
:loading="loading"
loading-text="发布中..."
@click="handleSubmit"
>
保存发布
</van-button>
</div>
</section>
<nav class="bottom-nav">
<RouterLink
to="/m"
class="nav-item"
:class="{ active: isNavActive('/m') }"
>
<van-icon name="home-o" :size="22" />
<span>首页</span>
</RouterLink>
<RouterLink
to="/m/messages"
class="nav-item"
:class="{ active: isNavActive('/m/messages') }"
>
<van-icon name="chat-o" :size="22" />
<span>消息</span>
</RouterLink>
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
<div class="publish-pill">+</div>
<span>发布</span>
</RouterLink>
<RouterLink
to="/m/orders"
class="nav-item"
:class="{ active: isNavActive('/m/orders') }"
>
<van-icon name="orders-o" :size="22" />
<span>订单</span>
</RouterLink>
<RouterLink
to="/m/profile"
class="nav-item"
:class="{ active: isNavActive('/m/profile') }"
>
<van-icon name="manager-o" :size="22" />
<span>我的</span>
</RouterLink>
</nav>
</main>
</template>
<style scoped src="./MobileSellerListingCreateView.css"></style>