diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 22c909d..79c066e 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -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 { diff --git a/frontend/src/api/listingOptions.ts b/frontend/src/api/listingOptions.ts index 158cc8d..618c8fa 100644 --- a/frontend/src/api/listingOptions.ts +++ b/frontend/src/api/listingOptions.ts @@ -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[] { diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue index 0cfaa2b..b6e8143 100644 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ b/frontend/src/views/admin/AdminSystemConfigsView.vue @@ -463,7 +463,7 @@ function readError(error: unknown, fallback: string) {

Sale Pricing

内部出售定价规则

- 管理出售加价和比例扣减规则,仅用于平台内部定价计算,不在发布页展示。 + 管理出售专用比例计算规则,仅用于平台内部定价计算,不在发布页展示。
编辑出售规则 @@ -474,7 +474,7 @@ function readError(error: unknown, fallback: string) {
{{ salePriceStats.ratioCount }} - 比例扣减 + 比例修正
内部 @@ -843,8 +843,8 @@ function readError(error: unknown, fallback: string) {
- 90M 以上比例调整:出售比例 = 回收比例 - 比例扣减;最大 M 为 0 表示无上限 - 添加比例调整 + 90M 以上比例修正:出售比例 = 回收比例 - 比例修正;最大 M 为 0 表示无上限 + 添加比例修正
@@ -853,7 +853,7 @@ function readError(error: unknown, fallback: string) { - + diff --git a/frontend/src/views/mobile/MobileSellerListingCreateView.css b/frontend/src/views/mobile/MobileSellerListingCreateView.css index 4ec19dd..9ccffc5 100644 --- a/frontend/src/views/mobile/MobileSellerListingCreateView.css +++ b/frontend/src/views/mobile/MobileSellerListingCreateView.css @@ -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; } diff --git a/frontend/src/views/mobile/MobileSellerListingCreateView.vue b/frontend/src/views/mobile/MobileSellerListingCreateView.vue index 255f63d..6c41882 100644 --- a/frontend/src/views/mobile/MobileSellerListingCreateView.vue +++ b/frontend/src/views/mobile/MobileSellerListingCreateView.vue @@ -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) {

比例调整:在默认10M/天,每增加10M,出租比例相应+1,请根据实际需求合理设置,感谢您的配合。

- -

- 请根据实际需求合理设置,感谢您的配合。 -

+
+
+ 参考比例 + {{ calculatedDefaultSaleRatioText }} +
+

{{ saleRatioRangeText }},比例越高出租速度越快,自行选择。

+
+ + +
+ +

+ 留空使用参考比例;填写后范围为参考比例到参考比例+10,请根据实际需求合理设置。 +

+

- 发布总价 = 纯币基础价 + 额外消耗品;加速比例仅影响纯币基础价。 + 卖家价格 = 纯币基础价 + 额外消耗品;加速比例仅影响纯币基础价。

).seller_total_price) + if (Number.isFinite(price) && price > 0) return price + } + return Number(row.price_daily || row.price_hourly || 0) }