修复定价错误

This commit is contained in:
yml2213
2026-05-23 22:31:27 +08:00
parent 78257097f1
commit e082921fa6
6 changed files with 273 additions and 78 deletions
@@ -493,6 +493,9 @@ func publicListings(items []ListingDTO) []ListingDTO {
func applyPublicListingURLs(item *ListingDTO) {
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
if item.AssetSummary != nil {
delete(item.AssetSummary, "price_breakdown")
}
}
func (row listingRow) toDTO() ListingDTO {
+20 -3
View File
@@ -126,8 +126,21 @@ export const emptyListingPublishOptions: ListingPublishOptions = {
}
export const emptyListingSalePriceConfig: PublishSalePriceConfig = {
fixed_markup_rules: [],
ratio_adjustment_rules: [],
fixed_markup_rules: [
{ min_m: 10, max_m: 30, markup_amount: 28 },
{ min_m: 30, max_m: 50, markup_amount: 31 },
{ min_m: 50, max_m: 70, markup_amount: 34 },
{ min_m: 70, max_m: 90, markup_amount: 38 },
],
ratio_adjustment_rules: [
{ min_m: 90, max_m: 150, ratio_subtract: 5 },
{ min_m: 150, max_m: 230, ratio_subtract: 4 },
{ min_m: 230, max_m: 310, ratio_subtract: 3.5 },
{ min_m: 310, max_m: 390, ratio_subtract: 3 },
{ min_m: 390, max_m: 470, ratio_subtract: 0 },
{ min_m: 470, max_m: 550, ratio_subtract: 0 },
{ min_m: 550, max_m: 0, ratio_subtract: 0 },
],
}
export async function fetchListingPublishOptions() {
@@ -240,7 +253,7 @@ function normalizeRatioConfig(value?: unknown): PublishRatioConfig {
function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
const row = isRecord(value) ? value : {}
return {
const config = {
fixed_markup_rules: normalizeSaleFixedMarkupRules(
Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : [],
),
@@ -248,6 +261,10 @@ function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : [],
),
}
if (config.fixed_markup_rules.length === 0 && config.ratio_adjustment_rules.length === 0) {
return JSON.parse(JSON.stringify(emptyListingSalePriceConfig)) as PublishSalePriceConfig
}
return config
}
function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] {
@@ -463,7 +463,7 @@ function readError(error: unknown, fallback: string) {
<div>
<p class="eyebrow">Sale Pricing</p>
<h2>内部出售定价规则</h2>
<span>管理出售加价和比例扣减规则仅用于平台内部定价计算不在发布页展示</span>
<span>管理出售专用比例计算规则仅用于平台内部定价计算不在发布页展示</span>
</div>
<el-button type="primary" @click="openEdit(salePriceConfig)">编辑出售规则</el-button>
</div>
@@ -474,7 +474,7 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="publish-stat">
<strong>{{ salePriceStats.ratioCount }}</strong>
<span>比例扣减</span>
<span>比例修正</span>
</div>
<div class="publish-stat">
<strong>内部</strong>
@@ -843,8 +843,8 @@ function readError(error: unknown, fallback: string) {
</el-table>
<div class="editor-block-title subtle-title">
<span>90M 以上比例调整出售比例 = 回收比例 - 比例扣减最大 M 0 表示无上限</span>
<el-button size="small" @click="addSaleRatioAdjustmentRule">添加比例调整</el-button>
<span>90M 以上比例修正出售比例 = 回收比例 - 比例修正最大 M 0 表示无上限</span>
<el-button size="small" @click="addSaleRatioAdjustmentRule">添加比例修正</el-button>
</div>
<el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
@@ -853,7 +853,7 @@ function readError(error: unknown, fallback: string) {
<el-table-column label="最大 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="比例扣减" min-width="130">
<el-table-column label="比例修正" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.ratio_subtract" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
@@ -498,6 +498,104 @@
font-weight: 800;
}
.ratio-panel {
display: grid;
gap: 10px;
margin-bottom: 8px;
}
.ratio-reference {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 48px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.ratio-reference span {
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.ratio-reference strong {
color: #ff6a00;
font-size: 18px;
font-weight: 900;
line-height: 1;
}
.ratio-range {
margin: 0;
color: #858c96;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
}
.ratio-mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ratio-mode-btn {
display: grid;
gap: 5px;
min-width: 0;
min-height: 74px;
padding: 12px 8px;
border: 1px solid #d7dce3;
border-radius: 8px;
background: #fff;
color: #30343a;
text-align: center;
}
.ratio-mode-btn strong,
.ratio-mode-btn span {
min-width: 0;
overflow-wrap: anywhere;
}
.ratio-mode-btn strong {
font-size: 14px;
font-weight: 800;
}
.ratio-mode-btn span {
color: #858c96;
font-size: 12px;
font-weight: 700;
line-height: 1.25;
}
.ratio-mode-btn.active {
border-color: #ff6a00;
background: #fff7f0;
color: #ff6a00;
}
.ratio-mode-btn.active span {
color: #ff6a00;
}
.ratio-mode-btn:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.ratio-input-field {
margin-bottom: 0;
}
.ratio-input-hint {
margin: -2px 0 0;
}
.total-price-field :deep(.van-field__control) {
font-size: 16px;
}
@@ -125,7 +125,6 @@ const fireLevelMin = computed(() => publishOptions.value.fire_level_min || 38);
const fireLevelPlaceholder = computed(
() => `等级低于${fireLevelMin.value}级的号无法发布`
);
const ratioConfig = computed(() => publishOptions.value.ratio_config);
const skinGroups = computed(() => publishOptions.value.skin_groups);
const quantityItems = computed(() => publishOptions.value.quantity_items);
const screenshotSlots = computed(() => publishOptions.value.screenshot_slots);
@@ -140,16 +139,14 @@ 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 calculatedRecycleRatio = computed(() => calculatePublishRatio());
const calculatedSalePricing = computed(() => calculateSalePricing());
const calculatedDefaultSaleRatio = computed(() => calculatedSalePricing.value.saleRatio);
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;
if (!hasAcceleratedSaleRatioInput()) return calculatedSalePricing.value.coinBasePrice;
return roundMoney(coinWanAmount.value / calculatedRatio.value);
});
const acceleratedSaleRatioPlaceholder = computed(() => {
@@ -157,14 +154,23 @@ const acceleratedSaleRatioPlaceholder = computed(() => {
return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}`;
});
const calculatedConsumablePrice = computed(() => calculateConsumablePrice());
const calculatedFinalPrice = computed(() =>
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();
@@ -403,6 +409,15 @@ function clampAcceleratedSaleRatioInput() {
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) {
@@ -505,19 +520,20 @@ function buildAssetSummary() {
secret_kd: form.secret_kd,
fire_level: Number(form.fire_level),
daily_loss_m: dailyLossMAmount.value,
publish_ratio: calculatedRatio.value,
publish_ratio: calculatedPlatformPricing.value.buyerRatio,
price_breakdown: {
recycle_coin_base_price: calculatedSalePricing.value.recycleCoinBasePrice,
default_coin_base_price: calculatedSalePricing.value.coinBasePrice,
coin_base_price: calculatedCoinBasePrice.value,
seller_reference_ratio: calculatedSellerReferenceRatio.value,
seller_ratio: calculatedRatio.value,
seller_coin_base_price: calculatedCoinBasePrice.value,
seller_total_price: calculatedSellerPrice.value,
consumable_price: calculatedConsumablePrice.value,
final_price: calculatedFinalPrice.value,
recycle_ratio: calculatedRecycleRatio.value,
daily_loss_ratio_adjustment: dailyLossRatioAdjustment.value,
default_sale_ratio: calculatedDefaultSaleRatio.value,
sale_ratio: calculatedRatio.value,
accelerated_sale_ratio: hasAcceleratedSaleRatioInput() ? Number(form.accelerated_sale_ratio) : calculatedDefaultSaleRatio.value,
sale_rule_type: calculatedSalePricing.value.ruleType,
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,
@@ -571,7 +587,7 @@ function readUnitPrice(priceText: string) {
return singleMatch ? Number(singleMatch[1]) : 0;
}
function calculatePublishRatio() {
function calculateSellerReferenceRatio() {
if (
coinMAmount.value <= 0 ||
!form.season_insurance ||
@@ -580,13 +596,13 @@ function calculatePublishRatio() {
) {
return 0;
}
const baseRatio = getInsuranceBaseRatio(form.season_insurance);
const baseRatio = getInsuranceBaseRatio(publishOptions.value.ratio_config, form.season_insurance);
if (baseRatio <= 0) return 0;
return (
baseRatio +
calculateConfigPenalty() +
getCoinCorrection(coinMAmount.value) +
calculateConfigPenalty(publishOptions.value.ratio_config) +
getCoinCorrection(publishOptions.value.ratio_config, coinMAmount.value) +
dailyLossRatioAdjustment.value
);
}
@@ -604,42 +620,44 @@ function hasAcceleratedSaleRatioInput() {
return form.accelerated_sale_ratio !== "" && form.accelerated_sale_ratio !== null;
}
function calculateSalePricing() {
const recycleRatio = calculatedRecycleRatio.value;
if (recycleRatio <= 0 || coinWanAmount.value <= 0) {
return emptySalePricing();
function calculatePlatformPricing() {
if (calculatedRatio.value <= 0 || calculatedCoinBasePrice.value <= 0) {
return emptyPlatformPricing();
}
const recycleCoinBasePrice = roundMoney(coinWanAmount.value / recycleRatio);
const fixedRule = findSaleFixedMarkupRule();
if (fixedRule) {
const coinBasePrice = roundMoney(recycleCoinBasePrice + Number(fixedRule.markup_amount || 0));
return {
recycleCoinBasePrice,
coinBasePrice,
saleRatio: calculateEffectiveRatio(coinBasePrice),
ruleType: "fixed_markup",
};
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 saleRatio = recycleRatio - ratioSubtract;
if (saleRatio <= 0) {
return emptySalePricing(recycleCoinBasePrice);
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 {
recycleCoinBasePrice,
coinBasePrice: roundMoney(coinWanAmount.value / saleRatio),
saleRatio,
ruleType: ratioRule ? "ratio_subtract" : "none",
buyerCoinBasePrice,
buyerTotalPrice,
buyerRatio: calculateEffectiveRatio(buyerCoinBasePrice),
platformMarkupAmount: roundMoney(buyerTotalPrice - calculatedSellerPrice.value),
ruleType,
};
}
function emptySalePricing(recycleCoinBasePrice = 0) {
function emptyPlatformPricing() {
return {
recycleCoinBasePrice,
coinBasePrice: 0,
saleRatio: 0,
buyerCoinBasePrice: 0,
buyerTotalPrice: 0,
buyerRatio: 0,
platformMarkupAmount: 0,
ruleType: "none",
};
}
@@ -647,18 +665,27 @@ function emptySalePricing(recycleCoinBasePrice = 0) {
function findSaleFixedMarkupRule() {
return [...salePriceConfig.value.fixed_markup_rules]
.sort((a, b) => a.min_m - b.min_m)
.find((item) => isCoinInSaleRange(item));
.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) => isCoinInSaleRange(item));
.find((item, index, rules) => isCoinInSaleRange(item, index, rules, { excludeFirstMin: true }));
}
function isCoinInSaleRange(item: { min_m: number; max_m: number }) {
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);
return coinMAmount.value >= Number(item.min_m || 0) && (maxM <= 0 || coinMAmount.value <= maxM);
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) {
@@ -666,14 +693,14 @@ function calculateEffectiveRatio(price: number) {
return roundRatio(coinWanAmount.value / price);
}
function getInsuranceBaseRatio(insurance: string) {
return ratioConfig.value.insurance_base_ratios.find(
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() {
return ratioConfig.value.config_items.reduce((sum, item) => {
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);
}
@@ -703,8 +730,8 @@ function readLevelNumber(value: string) {
return match ? Number(match[0]) : 0;
}
function getCoinCorrection(coinM: number) {
return [...ratioConfig.value.coin_corrections]
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;
}
@@ -1092,28 +1119,69 @@ function readError(error: unknown, fallback: string) {
<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="加速出售比例"
label="自定比例"
type="number"
:placeholder="acceleratedSaleRatioPlaceholder"
class="publish-field"
class="publish-field ratio-input-field"
@update:model-value="handleAcceleratedSaleRatioInput"
@blur="clampAcceleratedSaleRatioInput"
/>
<p class="field-hint">
请根据实际需求合理设置感谢您的配合
<p class="field-hint ratio-input-hint">
留空使用参考比例填写后范围为参考比例到参考比例+10请根据实际需求合理设置
</p>
</div>
<div class="result-grid">
<van-field
:model-value="calculatedRatioText"
label="出售比例"
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>
<span class="hint-label" :data-hint="priceConfig.ratio_description" tabindex="0">卖家比例</span>
</template>
</van-field>
<van-field
@@ -1135,8 +1203,8 @@ function readError(error: unknown, fallback: string) {
class="publish-field result-field"
/>
<van-field
:model-value="calculatedFinalPrice ? `¥${calculatedFinalPrice}` : ''"
label="发布总价"
:model-value="calculatedSellerPrice ? `¥${calculatedSellerPrice}` : ''"
label="卖家价格"
readonly
required
:placeholder="priceConfig.price_placeholder"
@@ -1144,7 +1212,7 @@ function readError(error: unknown, fallback: string) {
/>
</div>
<p class="price-breakdown-hint">
发布总价 = 纯币基础价 + 额外消耗品加速比例仅影响纯币基础价
卖家价格 = 纯币基础价 + 额外消耗品加速比例仅影响纯币基础价
</p>
<van-field
@@ -36,7 +36,16 @@ async function offline(id: number) {
}
function listingPrice(row: Listing) {
return `¥${Number(row.price_daily || row.price_hourly || 0).toFixed(2)}`
return `¥${readSellerPrice(row).toFixed(2)}`
}
function readSellerPrice(row: Listing) {
const breakdown = row.asset_summary?.price_breakdown
if (typeof breakdown === 'object' && breakdown !== null) {
const price = Number((breakdown as Record<string, unknown>).seller_total_price)
if (Number.isFinite(price) && price > 0) return price
}
return Number(row.price_daily || row.price_hourly || 0)
}
</script>