优化发布界面

This commit is contained in:
yml2213
2026-05-23 11:31:28 +08:00
parent ce554432c2
commit 367f26ffd1
13 changed files with 348 additions and 240 deletions
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import {
defaultListingPublishOptions,
emptyListingPublishOptions,
mergeListingPublishOptions,
type ListingPublishOptions,
} from '@/api/listingOptions'
@@ -23,7 +23,7 @@ const configs = ref<SystemConfig[]>([])
const activeConfig = ref<SystemConfig | null>(null)
const value = ref('')
const description = ref('')
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(defaultListingPublishOptions))
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListingPublishOptions))
const homeAnnouncementLines = ref(itemsToLines(defaultHomeAnnouncements))
const homeBannersDraft = ref<HomeBannerSlide[]>(cloneHomeBanners(defaultHomeBanners))
const uploadingBannerIndex = ref<number | null>(null)
@@ -141,20 +141,20 @@ async function handleSave() {
function parsePublishOptions(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultListingPublishOptions
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingPublishOptions
return cloneOptions(mergeListingPublishOptions(parsed))
} catch {
ElMessage.warning('发布选项 JSON 解析失败,已使用默认配置')
return cloneOptions(defaultListingPublishOptions)
ElMessage.warning('发布选项 JSON 解析失败,已清空草稿')
return cloneOptions(emptyListingPublishOptions)
}
}
function safeParsePublishOptions(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultListingPublishOptions
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingPublishOptions
return cloneOptions(mergeListingPublishOptions(parsed))
} catch {
return cloneOptions(defaultListingPublishOptions)
return cloneOptions(emptyListingPublishOptions)
}
}
@@ -222,7 +222,7 @@ function removeSkinGroup(index: number) {
function addQuantityItem() {
publishOptionsDraft.value.quantity_items.push({
key: `item_${Date.now()}` as never,
key: `item_${Date.now()}`,
label: '',
price: '',
})
@@ -234,7 +234,7 @@ function removeQuantityItem(index: number) {
function addScreenshotSlot() {
publishOptionsDraft.value.screenshot_slots.push({
key: `screenshot_${Date.now()}` as never,
key: `screenshot_${Date.now()}`,
label: '',
required: false,
hint: '',
@@ -245,10 +245,6 @@ function removeScreenshotSlot(index: number) {
publishOptionsDraft.value.screenshot_slots.splice(index, 1)
}
function resetPublishOptions() {
publishOptionsDraft.value = cloneOptions(defaultListingPublishOptions)
}
function addHomeBanner() {
homeBannersDraft.value.push({
eyebrow: '首页推荐',
@@ -330,7 +326,7 @@ function readError(error: unknown, fallback: string) {
<div>
<p class="eyebrow">Publish Options</p>
<h2>发布表单选项</h2>
<span>管理移动端发布页的区服段位皮肤资源道具截图材料和地区选项</span>
<span>管理移动端发布页的区服段位皮肤额外消耗品截图材料和地区选项</span>
</div>
<el-button type="primary" @click="openEdit(publishConfig)">编辑发布选项</el-button>
</div>
@@ -345,7 +341,7 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="publish-stat">
<strong>{{ publishStats.resourceCount }}</strong>
<span>资源道具</span>
<span>额外消耗品</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.screenshotCount }}</strong>
@@ -421,7 +417,6 @@ function readError(error: unknown, fallback: string) {
<div v-if="isPublishOptionsConfig" class="publish-options-editor">
<div class="editor-toolbar">
<span>发布页选项配置</span>
<el-button size="small" @click="resetPublishOptions">恢复默认选项</el-button>
</div>
<div class="editor-grid">
@@ -512,8 +507,8 @@ function readError(error: unknown, fallback: string) {
<div class="editor-block">
<div class="editor-block-title">
<strong>资源道具</strong>
<el-button size="small" @click="addQuantityItem">添加道具</el-button>
<strong>额外消耗品</strong>
<el-button size="small" @click="addQuantityItem">添加消耗品</el-button>
</div>
<el-table :data="publishOptionsDraft.quantity_items" size="small" border>
<el-table-column label="Key" min-width="150">
+3 -3
View File
@@ -4,7 +4,7 @@ import { RouterLink, useRoute } from "vue-router";
import { showToast } from "vant";
import {
defaultListingPublishOptions,
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/api/listingOptions";
import { fetchListings, type Listing } from "@/api/listings";
@@ -39,7 +39,7 @@ const route = useRoute();
const loading = ref(false);
const loadFailed = ref(false);
const listings = ref<Listing[]>([]);
const publishOptions = ref<ListingPublishOptions>(defaultListingPublishOptions);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const sortOpen = ref(false);
const activeSort = ref("comprehensive");
const filterOpen = ref(false);
@@ -176,7 +176,7 @@ async function loadHomeConfig() {
} catch {
announcements.value = defaultHomeAnnouncements;
bannerSlides.value = defaultHomeBanners;
publishOptions.value = defaultListingPublishOptions;
publishOptions.value = emptyListingPublishOptions;
}
}
@@ -268,7 +268,7 @@ function isNavActive(path: string) {
</div>
<div v-if="getListingResources(listing).length" class="info-card">
<h3 class="card-subtitle">资源道具</h3>
<h3 class="card-subtitle">额外消耗品</h3>
<div class="resource-grid">
<div
v-for="resource in getListingResources(listing)"
@@ -416,16 +416,6 @@
font-size: 11px;
}
.calc-btn {
height: 40px;
margin-bottom: 8px;
border: none;
border-radius: 8px;
background: #1477ff;
color: #fff;
font-weight: 700;
}
.result-field :deep(.van-field__control) {
color: #ff6a00;
font-weight: 800;
@@ -507,8 +497,7 @@
}
@media (min-width: 430px) {
.time-row,
.result-grid {
.time-row {
grid-template-columns: 1fr 1fr;
}
}
@@ -5,7 +5,7 @@ import { showDialog, showToast } from "vant";
import { uploadFile } from "@/api/files";
import {
defaultListingPublishOptions,
emptyListingPublishOptions,
fetchListingPublishOptions,
type ChargeMode,
type ListingPublishOptions,
@@ -30,9 +30,6 @@ type PublishForm = {
ban_record: string;
common_regions: string[];
deposit_amount: number | "";
ratio: number | "";
final_price: number;
rent_days: number;
remark: string;
};
@@ -50,7 +47,7 @@ const route = useRoute();
const loading = ref(false);
const uploading = ref(false);
const suppressDraftSave = ref(false);
const publishOptions = ref<ListingPublishOptions>(defaultListingPublishOptions);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
@@ -74,43 +71,20 @@ function defaultForm(): PublishForm {
ban_record: "",
common_regions: [],
deposit_amount: "",
ratio: "",
final_price: 0,
rent_days: 0,
remark: "",
};
}
function defaultQuantityValues(): Record<QuantityKey, number> {
return {
awmAmmo: 0,
helmet6: 0,
armor6: 0,
kit5: 0,
level6Ammo: 0,
gridCard9: 0,
};
return {};
}
function defaultQuantityModes(): Record<QuantityKey, ChargeMode> {
return {
awmAmmo: "收费",
helmet6: "收费",
armor6: "收费",
kit5: "收费",
level6Ammo: "收费",
gridCard9: "收费",
};
return {};
}
function defaultScreenshotFiles(): Record<ScreenshotKey, string> {
return {
coin: "",
gameId: "",
totalAsset: "",
tencentSecurity: "",
skin: "",
};
return {};
}
const form = reactive<PublishForm>(defaultForm());
@@ -138,10 +112,17 @@ 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(Boolean)
screenshotSlots.value
.map((item) => screenshotFiles[item.key])
.filter((url): url is string => Boolean(url))
);
const dailyLossText = computed(() =>
form.rent_days ? calculateDailyLoss(Number(form.haf_coin_amount || 0)) : ""
const coinWanAmount = computed(() => Number(form.haf_coin_amount || 0));
const calculatedRatio = computed(() => calculatePublishRatio());
const calculatedFinalPrice = computed(() =>
calculatedRatio.value > 0 ? roundMoney(coinWanAmount.value / calculatedRatio.value) : 0
);
const calculatedRentDays = computed(() =>
coinWanAmount.value > 0 ? calculateRentDays(coinWanAmount.value) : 0
);
onMounted(() => {
@@ -161,7 +142,7 @@ async function loadPublishOptions() {
try {
publishOptions.value = await fetchListingPublishOptions();
} catch {
publishOptions.value = defaultListingPublishOptions;
publishOptions.value = emptyListingPublishOptions;
}
}
@@ -188,10 +169,7 @@ function restoreDraft() {
if (!raw) return;
try {
const draft = JSON.parse(raw) as Partial<PublishDraft>;
Object.assign(form, defaultForm(), draft.form || {});
form.common_regions = Array.isArray(draft.form?.common_regions)
? [...draft.form.common_regions]
: [];
Object.assign(form, normalizeDraftForm(draft.form));
Object.assign(quantityValues, defaultQuantityValues(), draft.quantityValues || {});
Object.assign(quantityModes, defaultQuantityModes(), draft.quantityModes || {});
Object.assign(screenshotFiles, defaultScreenshotFiles(), draft.screenshotFiles || {});
@@ -203,6 +181,27 @@ function restoreDraft() {
}
}
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());
@@ -288,23 +287,6 @@ function handleFireLevelInput(value: string | number) {
form.fire_level = Math.trunc(level);
}
function calculatePrice() {
const coinWan = Number(form.haf_coin_amount || 0);
const ratio = Number(form.ratio || 0);
if (coinWan <= 0) {
showToast({ message: "请先填写哈夫币/万", icon: "warning-o" });
return;
}
if (ratio <= 0) {
showToast({ message: "比例需大于0", icon: "warning-o" });
return;
}
const finalPrice = roundMoney(coinWan / ratio);
const rentDays = coinWan >= 30000 ? 7 : coinWan >= 10000 ? 3 : 1;
form.final_price = finalPrice;
form.rent_days = rentDays;
}
async function handleSubmit() {
const error = validateForm();
if (error) {
@@ -314,8 +296,8 @@ async function handleSubmit() {
loading.value = true;
try {
const title = `${form.server_region} ${form.rank_level} ${form.haf_coin_amount}万哈夫币`;
const rentDays = Math.max(Number(form.rent_days || 1), 1);
const dailyPrice = roundMoney(Number(form.final_price) / rentDays);
const rentDays = Math.max(calculatedRentDays.value, 1);
const dailyPrice = roundMoney(calculatedFinalPrice.value / rentDays);
const hourlyPrice = Math.max(roundMoney(dailyPrice / 24), 0.01);
await createListing({
@@ -359,8 +341,8 @@ function validateForm() {
if (form.deposit_amount === "" || Number(form.deposit_amount) < 0) {
return "请填写押金";
}
if (!form.final_price || !form.rent_days) {
return "请先点击计算价格";
if (!calculatedFinalPrice.value || !calculatedRentDays.value) {
return "请完善币数、保险、体力和负重后再发布";
}
for (const item of screenshotSlots.value) {
if (item.required && !screenshotFiles[item.key]) {
@@ -382,7 +364,7 @@ function buildAssetSummary() {
key: item.key,
label: item.label,
quantity: Number(quantityValues[item.key] || 0),
mode: quantityModes[item.key],
mode: quantityModes[item.key] || "收费",
})),
skin_groups: skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
groups[group.key] = group.options.filter((skin) =>
@@ -404,10 +386,79 @@ function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function calculateDailyLoss(coinWan: number) {
if (coinWan >= 30000) return "30M";
if (coinWan >= 10000) return "20M";
return "10M";
function calculatePublishRatio() {
if (
coinWanAmount.value <= 0 ||
!form.season_insurance ||
!form.stamina_level ||
!form.load_level
) {
return 0;
}
const baseRatio = getInsuranceBaseRatio(form.season_insurance);
if (baseRatio <= 0) return 0;
return baseRatio + (5 - getConfigHitCount()) + getCoinCorrection(coinWanAmount.value);
}
function getInsuranceBaseRatio(insurance: string) {
const slots = getInsuranceSlots(insurance);
const ratioBySlots: Record<number, number> = {
9: 40,
6: 42,
4: 47,
2: 48,
};
return ratioBySlots[slots] || 0;
}
function getInsuranceSlots(insurance: string) {
const match = insurance.match(/^(\d+)\*(\d+)$/);
if (!match) return 0;
return Number(match[1]) * Number(match[2]);
}
function getConfigHitCount() {
const checks = [
hasSelectedSkinGroup("operator"),
hasSelectedSkinGroup("melee"),
isMaxLevel(form.stamina_level),
isMaxLevel(form.load_level),
hasSelectedSkinGroup("weapon"),
];
return checks.filter(Boolean).length;
}
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(coinWan: number) {
if (coinWan > 53000) return 5;
if (coinWan > 38000) return 4;
if (coinWan > 23000) return 2.5;
if (coinWan > 13000) return 1;
return 0;
}
function calculateRentDays(coinWan: number) {
if (coinWan >= 30000) return 7;
if (coinWan >= 10000) return 3;
return 1;
}
function readError(error: unknown, fallback: string) {
@@ -562,7 +613,7 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="form-section">
<h2 class="section-title">资源道具</h2>
<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">
@@ -588,7 +639,7 @@ function readError(error: unknown, fallback: string) {
</button>
<button
type="button"
:class="{ active: quantityModes[item.key] === '收费' }"
:class="{ active: (quantityModes[item.key] || '收费') === '收费' }"
@click="quantityModes[item.key] = '收费'"
>
收费
@@ -731,7 +782,7 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="form-section">
<h2 class="section-title">价格结算</h2>
<h2 class="section-title">押金与价格</h2>
<van-field
v-model="form.deposit_amount"
label="押金"
@@ -741,49 +792,13 @@ function readError(error: unknown, fallback: string) {
suffix="元"
class="publish-field"
/>
<van-field
v-model="form.ratio"
label="比例"
type="number"
placeholder="完成基础信息填写后请点击计算比例价格 1:请点击计算价格获取比例,最低比例0-建议6,比例越高出的速度越快,自行选择"
class="publish-field"
>
<template #left-icon>1:</template>
</van-field>
<van-button block class="calc-btn" @click="calculatePrice">
计算价格
</van-button>
<div class="result-grid">
<van-field
:model-value="form.final_price ? `¥${form.final_price}` : ''"
:model-value="calculatedFinalPrice ? `¥${calculatedFinalPrice}` : ''"
label="价格"
readonly
required
placeholder="请点击计算价格"
class="publish-field result-field"
/>
<van-field
:model-value="form.final_price ? `¥${form.final_price}` : ''"
label="结算金额"
readonly
required
placeholder="请点击计算价格获取结算金额"
class="publish-field result-field"
/>
<van-field
:model-value="form.rent_days ? `${form.rent_days}` : ''"
label="租期"
readonly
placeholder="自动计算后显示"
class="publish-field result-field"
/>
<van-field
:model-value="dailyLossText"
label="每日损耗"
readonly
placeholder="自动计算 10M/20M/30M"
placeholder="填写币数、保险、体力和负重后自动计算"
class="publish-field result-field"
/>
</div>
+3 -3
View File
@@ -16,7 +16,7 @@ import {
type HomeBannerSlide,
} from "@/api/homeConfig";
import {
defaultListingPublishOptions,
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/api/listingOptions";
import { fetchListings, type Listing } from "@/api/listings";
@@ -37,7 +37,7 @@ const loading = ref(false);
const listings = ref<Listing[]>([]);
const announcements = ref<string[]>(defaultHomeAnnouncements);
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners);
const publishOptions = ref<ListingPublishOptions>(defaultListingPublishOptions);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const filters = reactive({
keyword: "",
server: "",
@@ -131,7 +131,7 @@ async function loadHome() {
} catch {
announcements.value = defaultHomeAnnouncements;
banners.value = defaultHomeBanners;
publishOptions.value = defaultListingPublishOptions;
publishOptions.value = emptyListingPublishOptions;
} finally {
loading.value = false;
}