内部加价分离, 不展示

This commit is contained in:
yml2213
2026-05-23 21:07:40 +08:00
parent 9f15609afd
commit 78257097f1
11 changed files with 347 additions and 117 deletions
@@ -52,7 +52,6 @@ type PublishOptionsDTO struct {
FireLevelMin int `json:"fire_level_min"` FireLevelMin int `json:"fire_level_min"`
PriceConfig PublishPriceConfig `json:"price_config"` PriceConfig PublishPriceConfig `json:"price_config"`
RatioConfig PublishRatioConfig `json:"ratio_config"` RatioConfig PublishRatioConfig `json:"ratio_config"`
SalePriceConfig PublishSalePriceConfig `json:"sale_price_config"`
} }
type PublishOptionGroup struct { type PublishOptionGroup struct {
@@ -36,6 +36,15 @@ func (h *Handler) PublishOptions(c *gin.Context) {
response.OK(c, options) response.OK(c, options)
} }
func (h *Handler) SalePriceConfig(c *gin.Context) {
config, err := h.service.SalePriceConfig()
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, config)
}
func (h *Handler) HomeAnnouncements(c *gin.Context) { func (h *Handler) HomeAnnouncements(c *gin.Context) {
announcements, err := h.service.HomeAnnouncements() announcements, err := h.service.HomeAnnouncements()
if err != nil { if err != nil {
@@ -108,22 +108,5 @@ func DefaultPublishOptions() PublishOptionsDTO {
{ThresholdM: 530, Correction: 5}, {ThresholdM: 530, Correction: 5},
}, },
}, },
SalePriceConfig: PublishSalePriceConfig{
FixedMarkupRules: []PublishSaleFixedMarkupRule{
{MinM: 10, MaxM: 30, MarkupAmount: 28},
{MinM: 30, MaxM: 50, MarkupAmount: 31},
{MinM: 50, MaxM: 70, MarkupAmount: 34},
{MinM: 70, MaxM: 90, MarkupAmount: 38},
},
RatioAdjustmentRules: []PublishSaleRatioAdjustmentRule{
{MinM: 90, MaxM: 150, RatioSubtract: 5},
{MinM: 150, MaxM: 230, RatioSubtract: 4},
{MinM: 230, MaxM: 310, RatioSubtract: 3.5},
{MinM: 310, MaxM: 390, RatioSubtract: 3},
{MinM: 390, MaxM: 470, RatioSubtract: 0},
{MinM: 470, MaxM: 550, RatioSubtract: 0},
{MinM: 550, RatioSubtract: 0},
},
},
} }
} }
@@ -43,6 +43,7 @@ var defaultConfigs = []defaultConfig{
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
{Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"},
} }
func NewRepository(db *gorm.DB) *Repository { func NewRepository(db *gorm.DB) *Repository {
@@ -0,0 +1,33 @@
package systemconfig
import "encoding/json"
const salePriceConfigKey = "listing.sale_price_config"
func defaultSalePriceConfigValue() string {
raw, err := json.Marshal(DefaultSalePriceConfig())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultSalePriceConfig() PublishSalePriceConfig {
return PublishSalePriceConfig{
FixedMarkupRules: []PublishSaleFixedMarkupRule{
{MinM: 10, MaxM: 30, MarkupAmount: 28},
{MinM: 30, MaxM: 50, MarkupAmount: 31},
{MinM: 50, MaxM: 70, MarkupAmount: 34},
{MinM: 70, MaxM: 90, MarkupAmount: 38},
},
RatioAdjustmentRules: []PublishSaleRatioAdjustmentRule{
{MinM: 90, MaxM: 150, RatioSubtract: 5},
{MinM: 150, MaxM: 230, RatioSubtract: 4},
{MinM: 230, MaxM: 310, RatioSubtract: 3.5},
{MinM: 310, MaxM: 390, RatioSubtract: 3},
{MinM: 390, MaxM: 470, RatioSubtract: 0},
{MinM: 470, MaxM: 550, RatioSubtract: 0},
{MinM: 550, RatioSubtract: 0},
},
}
}
@@ -37,6 +37,17 @@ func (s *Service) PublishOptions() (*PublishOptionsDTO, error) {
return &options, nil return &options, nil
} }
func (s *Service) SalePriceConfig() (*PublishSalePriceConfig, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
config, err := s.salePriceConfig()
if err != nil {
return nil, err
}
return &config, nil
}
func (s *Service) publishOptions() (PublishOptionsDTO, error) { func (s *Service) publishOptions() (PublishOptionsDTO, error) {
value, err := s.repo.FindValue(publishOptionsConfigKey) value, err := s.repo.FindValue(publishOptionsConfigKey)
if err != nil { if err != nil {
@@ -50,6 +61,19 @@ func (s *Service) publishOptions() (PublishOptionsDTO, error) {
return options, nil return options, nil
} }
func (s *Service) salePriceConfig() (PublishSalePriceConfig, error) {
value, err := s.repo.FindValue(salePriceConfigKey)
if err != nil {
return PublishSalePriceConfig{}, err
}
config := DefaultSalePriceConfig()
if err := json.Unmarshal([]byte(value), &config); err != nil {
config = DefaultSalePriceConfig()
}
normalizeSalePriceConfig(&config)
return config, nil
}
func (s *Service) HomeAnnouncements() (*HomeAnnouncementsDTO, error) { func (s *Service) HomeAnnouncements() (*HomeAnnouncementsDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
@@ -214,10 +238,14 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
if len(options.RatioConfig.CoinCorrections) == 0 { if len(options.RatioConfig.CoinCorrections) == 0 {
options.RatioConfig.CoinCorrections = defaults.RatioConfig.CoinCorrections options.RatioConfig.CoinCorrections = defaults.RatioConfig.CoinCorrections
} }
if len(options.SalePriceConfig.FixedMarkupRules) == 0 { }
options.SalePriceConfig.FixedMarkupRules = defaults.SalePriceConfig.FixedMarkupRules
func normalizeSalePriceConfig(config *PublishSalePriceConfig) {
defaults := DefaultSalePriceConfig()
if len(config.FixedMarkupRules) == 0 {
config.FixedMarkupRules = defaults.FixedMarkupRules
} }
if len(options.SalePriceConfig.RatioAdjustmentRules) == 0 { if len(config.RatioAdjustmentRules) == 0 {
options.SalePriceConfig.RatioAdjustmentRules = defaults.SalePriceConfig.RatioAdjustmentRules config.RatioAdjustmentRules = defaults.RatioAdjustmentRules
} }
} }
+1
View File
@@ -127,6 +127,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
{ {
api.GET("/health", health.Check) api.GET("/health", health.Check)
api.GET("/listing-publish-options", systemConfigHandler.PublishOptions) api.GET("/listing-publish-options", systemConfigHandler.PublishOptions)
api.GET("/listing-sale-price-config", systemConfigHandler.SalePriceConfig)
api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements) api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements)
api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) api.GET("/mobile-home-config", systemConfigHandler.HomeConfig)
api.GET("/public/files/object", fileHandler.PublicObject) api.GET("/public/files/object", fileHandler.PublicObject)
+12 -4
View File
@@ -91,7 +91,6 @@ export interface ListingPublishOptions {
fire_level_min: number fire_level_min: number
price_config: PublishPriceConfig price_config: PublishPriceConfig
ratio_config: PublishRatioConfig ratio_config: PublishRatioConfig
sale_price_config: PublishSalePriceConfig
} }
interface ApiResponse<T> { interface ApiResponse<T> {
@@ -124,10 +123,11 @@ export const emptyListingPublishOptions: ListingPublishOptions = {
config_items: [], config_items: [],
coin_corrections: [], coin_corrections: [],
}, },
sale_price_config: { }
export const emptyListingSalePriceConfig: PublishSalePriceConfig = {
fixed_markup_rules: [], fixed_markup_rules: [],
ratio_adjustment_rules: [], ratio_adjustment_rules: [],
},
} }
export async function fetchListingPublishOptions() { export async function fetchListingPublishOptions() {
@@ -135,6 +135,11 @@ export async function fetchListingPublishOptions() {
return mergeListingPublishOptions(data.data) return mergeListingPublishOptions(data.data)
} }
export async function fetchListingSalePriceConfig() {
const { data } = await apiClient.get<ApiResponse<PublishSalePriceConfig>>('/listing-sale-price-config')
return mergeListingSalePriceConfig(data.data)
}
export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions { export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions {
return { return {
server_options: normalizeStringList(options?.server_options), server_options: normalizeStringList(options?.server_options),
@@ -152,10 +157,13 @@ export function mergeListingPublishOptions(options?: Partial<ListingPublishOptio
fire_level_min: readPositiveInteger(options?.fire_level_min, 38), fire_level_min: readPositiveInteger(options?.fire_level_min, 38),
price_config: normalizePriceConfig(options?.price_config), price_config: normalizePriceConfig(options?.price_config),
ratio_config: normalizeRatioConfig(options?.ratio_config), ratio_config: normalizeRatioConfig(options?.ratio_config),
sale_price_config: normalizeSalePriceConfig(options?.sale_price_config),
} }
} }
export function mergeListingSalePriceConfig(options?: Partial<PublishSalePriceConfig>): PublishSalePriceConfig {
return normalizeSalePriceConfig(options)
}
function normalizeStringList(values?: unknown[]) { function normalizeStringList(values?: unknown[]) {
return Array.isArray(values) return Array.isArray(values)
? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) ? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
@@ -3,9 +3,12 @@ import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { import {
emptyListingSalePriceConfig,
emptyListingPublishOptions, emptyListingPublishOptions,
mergeListingSalePriceConfig,
mergeListingPublishOptions, mergeListingPublishOptions,
type ListingPublishOptions, type ListingPublishOptions,
type PublishSalePriceConfig,
} from '@/api/listingOptions' } from '@/api/listingOptions'
import { import {
defaultHomeAnnouncements, defaultHomeAnnouncements,
@@ -24,22 +27,26 @@ const activeConfig = ref<SystemConfig | null>(null)
const value = ref('') const value = ref('')
const description = ref('') const description = ref('')
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListingPublishOptions)) const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListingPublishOptions))
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig))
const homeAnnouncementLines = ref(itemsToLines(defaultHomeAnnouncements)) const homeAnnouncementLines = ref(itemsToLines(defaultHomeAnnouncements))
const homeBannersDraft = ref<HomeBannerSlide[]>(cloneHomeBanners(defaultHomeBanners)) const homeBannersDraft = ref<HomeBannerSlide[]>(cloneHomeBanners(defaultHomeBanners))
const uploadingBannerIndex = ref<number | null>(null) const uploadingBannerIndex = ref<number | null>(null)
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null) const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
const salePriceConfig = computed(() => configs.value.find((item) => item.key === 'listing.sale_price_config') || null)
const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null) const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null)
const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null) const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null)
const regularConfigs = computed(() => const regularConfigs = computed(() =>
configs.value.filter( configs.value.filter(
(item) => (item) =>
item.key !== 'listing.publish_options' && item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners', item.key !== 'mobile.home_banners',
), ),
) )
const isPublishOptionsConfig = computed(() => activeConfig.value?.key === 'listing.publish_options') const isPublishOptionsConfig = computed(() => activeConfig.value?.key === 'listing.publish_options')
const isSalePriceConfig = computed(() => activeConfig.value?.key === 'listing.sale_price_config')
const isHomeAnnouncementsConfig = computed(() => activeConfig.value?.key === 'mobile.home_announcements') const isHomeAnnouncementsConfig = computed(() => activeConfig.value?.key === 'mobile.home_announcements')
const isHomeBannersConfig = computed(() => activeConfig.value?.key === 'mobile.home_banners') const isHomeBannersConfig = computed(() => activeConfig.value?.key === 'mobile.home_banners')
const publishStats = computed(() => { const publishStats = computed(() => {
@@ -54,9 +61,13 @@ const publishStats = computed(() => {
resourceCount: options.quantity_items.length, resourceCount: options.quantity_items.length,
screenshotCount: options.screenshot_slots.length, screenshotCount: options.screenshot_slots.length,
regionCount: options.region_options.length, regionCount: options.region_options.length,
saleRuleCount: }
options.sale_price_config.fixed_markup_rules.length + })
options.sale_price_config.ratio_adjustment_rules.length, const salePriceStats = computed(() => {
const config = safeParseSalePriceConfig(salePriceConfig.value?.value || '')
return {
fixedCount: config.fixed_markup_rules.length,
ratioCount: config.ratio_adjustment_rules.length,
} }
}) })
const homeStats = computed(() => { const homeStats = computed(() => {
@@ -72,6 +83,7 @@ const isStructuredConfig = computed(() => {
const trimmed = value.value.trim() const trimmed = value.value.trim()
return ( return (
isPublishOptionsConfig.value || isPublishOptionsConfig.value ||
isSalePriceConfig.value ||
isHomeAnnouncementsConfig.value || isHomeAnnouncementsConfig.value ||
isHomeBannersConfig.value || isHomeBannersConfig.value ||
trimmed.startsWith('{') || trimmed.startsWith('{') ||
@@ -80,7 +92,7 @@ const isStructuredConfig = computed(() => {
}) })
const dialogWidth = computed(() => { const dialogWidth = computed(() => {
if (isPublishOptionsConfig.value || isHomeBannersConfig.value) return '920px' if (isPublishOptionsConfig.value || isSalePriceConfig.value || isHomeBannersConfig.value) return '920px'
if (isHomeAnnouncementsConfig.value) return '680px' if (isHomeAnnouncementsConfig.value) return '680px'
return '560px' return '560px'
}) })
@@ -103,6 +115,9 @@ function openEdit(row: SystemConfig) {
if (row.key === 'listing.publish_options') { if (row.key === 'listing.publish_options') {
publishOptionsDraft.value = parsePublishOptions(row.value) publishOptionsDraft.value = parsePublishOptions(row.value)
} }
if (row.key === 'listing.sale_price_config') {
salePriceConfigDraft.value = parseSalePriceConfig(row.value)
}
if (row.key === 'mobile.home_announcements') { if (row.key === 'mobile.home_announcements') {
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(row.value, true)) homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(row.value, true))
} }
@@ -118,6 +133,9 @@ async function handleSave() {
if (isPublishOptionsConfig.value) { if (isPublishOptionsConfig.value) {
value.value = JSON.stringify(publishOptionsDraft.value, null, 2) value.value = JSON.stringify(publishOptionsDraft.value, null, 2)
} }
if (isSalePriceConfig.value) {
value.value = JSON.stringify(salePriceConfigDraft.value, null, 2)
}
if (isHomeAnnouncementsConfig.value) { if (isHomeAnnouncementsConfig.value) {
value.value = JSON.stringify(linesToItems(homeAnnouncementLines.value), null, 2) value.value = JSON.stringify(linesToItems(homeAnnouncementLines.value), null, 2)
} }
@@ -161,6 +179,25 @@ function safeParsePublishOptions(raw: string) {
} }
} }
function parseSalePriceConfig(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingSalePriceConfig
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
} catch {
ElMessage.warning('出售定价规则 JSON 解析失败,已清空草稿')
return cloneSalePriceConfig(emptyListingSalePriceConfig)
}
}
function safeParseSalePriceConfig(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingSalePriceConfig
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
} catch {
return cloneSalePriceConfig(emptyListingSalePriceConfig)
}
}
function parseHomeAnnouncements(raw: string, showWarning = false) { function parseHomeAnnouncements(raw: string, showWarning = false) {
try { try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultHomeAnnouncements const parsed = raw.trim() ? JSON.parse(raw) : defaultHomeAnnouncements
@@ -185,6 +222,10 @@ function cloneOptions(options: ListingPublishOptions) {
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
} }
function cloneSalePriceConfig(config: PublishSalePriceConfig) {
return JSON.parse(JSON.stringify(config)) as PublishSalePriceConfig
}
function cloneHomeBanners(banners: HomeBannerSlide[]) { function cloneHomeBanners(banners: HomeBannerSlide[]) {
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[] return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
} }
@@ -285,7 +326,7 @@ function removeCoinCorrection(index: number) {
} }
function addSaleFixedMarkupRule() { function addSaleFixedMarkupRule() {
publishOptionsDraft.value.sale_price_config.fixed_markup_rules.push({ salePriceConfigDraft.value.fixed_markup_rules.push({
min_m: 0, min_m: 0,
max_m: 0, max_m: 0,
markup_amount: 0, markup_amount: 0,
@@ -293,11 +334,11 @@ function addSaleFixedMarkupRule() {
} }
function removeSaleFixedMarkupRule(index: number) { function removeSaleFixedMarkupRule(index: number) {
publishOptionsDraft.value.sale_price_config.fixed_markup_rules.splice(index, 1) salePriceConfigDraft.value.fixed_markup_rules.splice(index, 1)
} }
function addSaleRatioAdjustmentRule() { function addSaleRatioAdjustmentRule() {
publishOptionsDraft.value.sale_price_config.ratio_adjustment_rules.push({ salePriceConfigDraft.value.ratio_adjustment_rules.push({
min_m: 0, min_m: 0,
max_m: 0, max_m: 0,
ratio_subtract: 0, ratio_subtract: 0,
@@ -305,7 +346,7 @@ function addSaleRatioAdjustmentRule() {
} }
function removeSaleRatioAdjustmentRule(index: number) { function removeSaleRatioAdjustmentRule(index: number) {
publishOptionsDraft.value.sale_price_config.ratio_adjustment_rules.splice(index, 1) salePriceConfigDraft.value.ratio_adjustment_rules.splice(index, 1)
} }
function addHomeBanner() { function addHomeBanner() {
@@ -414,9 +455,38 @@ function readError(error: unknown, fallback: string) {
<strong>{{ publishStats.regionCount }}</strong> <strong>{{ publishStats.regionCount }}</strong>
<span>地区</span> <span>地区</span>
</div> </div>
</div>
</section>
<section v-if="salePriceConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Sale Pricing</p>
<h2>内部出售定价规则</h2>
<span>管理出售加价和比例扣减规则仅用于平台内部定价计算不在发布页展示</span>
</div>
<el-button type="primary" @click="openEdit(salePriceConfig)">编辑出售规则</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat"> <div class="publish-stat">
<strong>{{ publishStats.saleRuleCount }}</strong> <strong>{{ salePriceStats.fixedCount }}</strong>
<span>出售规则</span> <span>固定加价</span>
</div>
<div class="publish-stat">
<strong>{{ salePriceStats.ratioCount }}</strong>
<span>比例扣减</span>
</div>
<div class="publish-stat">
<strong>内部</strong>
<span>不展示给发布用户</span>
</div>
<div class="publish-stat">
<strong>配置</strong>
<span>listing.sale_price_config</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(salePriceConfig, '未初始化') }}</span>
</div> </div>
</div> </div>
</section> </section>
@@ -673,53 +743,6 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<div class="editor-block">
<div class="editor-block-title">
<strong>出售加价规则</strong>
</div>
<div class="editor-block-title subtle-title">
<span>90M 及以下固定加价出售价格 = 回收价格 + 固定加价</span>
<el-button size="small" @click="addSaleFixedMarkupRule">添加固定加价</el-button>
</div>
<el-table :data="publishOptionsDraft.sale_price_config.fixed_markup_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<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="140">
<template #default="{ row }"><el-input-number v-model="row.markup_amount" :min="0" :step="1" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleFixedMarkupRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>90M 以上比例调整出售比例 = 回收比例 - 比例扣减最大 M 0 表示无上限</span>
<el-button size="small" @click="addSaleRatioAdjustmentRule">添加比例调整</el-button>
</div>
<el-table :data="publishOptionsDraft.sale_price_config.ratio_adjustment_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<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">
<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">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleRatioAdjustmentRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="editor-block"> <div class="editor-block">
<div class="editor-block-title"> <div class="editor-block-title">
<strong>皮肤分类</strong> <strong>皮肤分类</strong>
@@ -792,6 +815,56 @@ function readError(error: unknown, fallback: string) {
</div> </div>
</div> </div>
<div v-else-if="isSalePriceConfig" class="publish-options-editor">
<div class="editor-toolbar">
<span>内部出售定价规则</span>
</div>
<div class="editor-block">
<div class="editor-block-title subtle-title">
<span>90M 及以下固定加价出售价格 = 回收价格 + 固定加价</span>
<el-button size="small" @click="addSaleFixedMarkupRule">添加固定加价</el-button>
</div>
<el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<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="140">
<template #default="{ row }"><el-input-number v-model="row.markup_amount" :min="0" :step="1" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleFixedMarkupRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<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">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<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">
<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">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleRatioAdjustmentRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-else-if="isHomeAnnouncementsConfig" class="home-config-editor"> <div v-else-if="isHomeAnnouncementsConfig" class="home-config-editor">
<div class="editor-toolbar"> <div class="editor-toolbar">
<span>首页公告</span> <span>首页公告</span>
@@ -498,12 +498,6 @@
font-weight: 800; font-weight: 800;
} }
.muted-result-field :deep(.van-field__control) {
color: #5c6470;
font-size: 12px;
font-weight: 700;
}
.total-price-field :deep(.van-field__control) { .total-price-field :deep(.van-field__control) {
font-size: 16px; font-size: 16px;
} }
@@ -5,10 +5,13 @@ import { showDialog, showToast } from "vant";
import { fetchFileBlobByURL, uploadFile } from "@/api/files"; import { fetchFileBlobByURL, uploadFile } from "@/api/files";
import { import {
emptyListingSalePriceConfig,
emptyListingPublishOptions, emptyListingPublishOptions,
fetchListingPublishOptions, fetchListingPublishOptions,
fetchListingSalePriceConfig,
type ChargeMode, type ChargeMode,
type ListingPublishOptions, type ListingPublishOptions,
type PublishSalePriceConfig,
type QuantityKey, type QuantityKey,
type ScreenshotKey, type ScreenshotKey,
} from "@/api/listingOptions"; } from "@/api/listingOptions";
@@ -21,6 +24,8 @@ type PublishForm = {
rank_level: string; rank_level: string;
secret_kd: string; secret_kd: string;
fire_level: number | ""; fire_level: number | "";
daily_loss_m: number | "";
accelerated_sale_ratio: number | "";
season_insurance: string; season_insurance: string;
stamina_level: string; stamina_level: string;
load_level: string; load_level: string;
@@ -42,12 +47,14 @@ interface PublishDraft {
} }
const draftKey = "hfb.mobile.publish.draft"; const draftKey = "hfb.mobile.publish.draft";
const dailyLossOptions = [10, 20, 30, 40, 50];
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const loading = ref(false); const loading = ref(false);
const uploading = ref(false); const uploading = ref(false);
const suppressDraftSave = ref(false); const suppressDraftSave = ref(false);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions); const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig);
function isNavActive(path: string) { function isNavActive(path: string) {
if (path === "/m") return route.path === "/m"; if (path === "/m") return route.path === "/m";
@@ -62,6 +69,8 @@ function defaultForm(): PublishForm {
rank_level: "", rank_level: "",
secret_kd: "", secret_kd: "",
fire_level: "", fire_level: "",
daily_loss_m: 10,
accelerated_sale_ratio: "",
season_insurance: "", season_insurance: "",
stamina_level: "", stamina_level: "",
load_level: "", load_level: "",
@@ -117,7 +126,6 @@ const fireLevelPlaceholder = computed(
() => `等级低于${fireLevelMin.value}级的号无法发布` () => `等级低于${fireLevelMin.value}级的号无法发布`
); );
const ratioConfig = computed(() => publishOptions.value.ratio_config); const ratioConfig = computed(() => publishOptions.value.ratio_config);
const salePriceConfig = computed(() => publishOptions.value.sale_price_config);
const skinGroups = computed(() => publishOptions.value.skin_groups); const skinGroups = computed(() => publishOptions.value.skin_groups);
const quantityItems = computed(() => publishOptions.value.quantity_items); const quantityItems = computed(() => publishOptions.value.quantity_items);
const screenshotSlots = computed(() => publishOptions.value.screenshot_slots); const screenshotSlots = computed(() => publishOptions.value.screenshot_slots);
@@ -128,11 +136,26 @@ const screenshotUrls = computed(() =>
); );
const coinMAmount = computed(() => Number(form.haf_coin_amount || 0)); const coinMAmount = computed(() => Number(form.haf_coin_amount || 0));
const coinWanAmount = computed(() => coinMAmount.value * 100); 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 calculatedRecycleRatio = computed(() => calculatePublishRatio()); const calculatedRecycleRatio = computed(() => calculatePublishRatio());
const calculatedSalePricing = computed(() => calculateSalePricing()); const calculatedSalePricing = computed(() => calculateSalePricing());
const calculatedRatio = computed(() => calculatedSalePricing.value.saleRatio); const calculatedDefaultSaleRatio = computed(() => calculatedSalePricing.value.saleRatio);
const calculatedCoinBasePrice = computed(() => calculatedSalePricing.value.coinBasePrice); const maxAcceleratedSaleRatio = computed(() =>
const calculatedSaleRuleText = computed(() => calculatedSalePricing.value.ruleText); 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(() => {
if (calculatedDefaultSaleRatio.value <= 0) return "填写资料后自动生成可设置范围";
return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}`;
});
const calculatedConsumablePrice = computed(() => calculateConsumablePrice()); const calculatedConsumablePrice = computed(() => calculateConsumablePrice());
const calculatedFinalPrice = computed(() => const calculatedFinalPrice = computed(() =>
calculatedRatio.value > 0 calculatedRatio.value > 0
@@ -162,9 +185,15 @@ watch(
async function loadPublishOptions() { async function loadPublishOptions() {
try { try {
publishOptions.value = await fetchListingPublishOptions(); const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
fetchListingPublishOptions(),
fetchListingSalePriceConfig(),
]);
publishOptions.value = nextPublishOptions;
salePriceConfig.value = nextSalePriceConfig;
} catch { } catch {
publishOptions.value = emptyListingPublishOptions; publishOptions.value = emptyListingPublishOptions;
salePriceConfig.value = emptyListingSalePriceConfig;
} }
} }
@@ -353,6 +382,27 @@ function handleFireLevelInput(value: string | number) {
form.fire_level = Math.trunc(level); 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));
}
async function handleSubmit() { async function handleSubmit() {
const error = validateForm(); const error = validateForm();
if (error) { if (error) {
@@ -409,6 +459,17 @@ function validateForm() {
if (!form.season_insurance) return "请选择赛季保险"; if (!form.season_insurance) return "请选择赛季保险";
if (!form.stamina_level) return "请选择体力等级"; if (!form.stamina_level) return "请选择体力等级";
if (!form.load_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 (banRecordOptions.value.length && !form.ban_record) return "请选择封禁记录";
if (form.deposit_amount === "" || Number(form.deposit_amount) < 0) { if (form.deposit_amount === "" || Number(form.deposit_amount) < 0) {
return "请填写押金"; return "请填写押金";
@@ -443,16 +504,20 @@ function buildAssetSummary() {
face_owner: form.face_owner, face_owner: form.face_owner,
secret_kd: form.secret_kd, secret_kd: form.secret_kd,
fire_level: Number(form.fire_level), fire_level: Number(form.fire_level),
daily_loss_m: dailyLossMAmount.value,
publish_ratio: calculatedRatio.value, publish_ratio: calculatedRatio.value,
price_breakdown: { price_breakdown: {
recycle_coin_base_price: calculatedSalePricing.value.recycleCoinBasePrice, recycle_coin_base_price: calculatedSalePricing.value.recycleCoinBasePrice,
default_coin_base_price: calculatedSalePricing.value.coinBasePrice,
coin_base_price: calculatedCoinBasePrice.value, coin_base_price: calculatedCoinBasePrice.value,
consumable_price: calculatedConsumablePrice.value, consumable_price: calculatedConsumablePrice.value,
final_price: calculatedFinalPrice.value, final_price: calculatedFinalPrice.value,
recycle_ratio: calculatedRecycleRatio.value, recycle_ratio: calculatedRecycleRatio.value,
daily_loss_ratio_adjustment: dailyLossRatioAdjustment.value,
default_sale_ratio: calculatedDefaultSaleRatio.value,
sale_ratio: calculatedRatio.value, sale_ratio: calculatedRatio.value,
accelerated_sale_ratio: hasAcceleratedSaleRatioInput() ? Number(form.accelerated_sale_ratio) : calculatedDefaultSaleRatio.value,
sale_rule_type: calculatedSalePricing.value.ruleType, sale_rule_type: calculatedSalePricing.value.ruleType,
sale_rule_text: calculatedSalePricing.value.ruleText,
}, },
season_insurance: form.season_insurance, season_insurance: form.season_insurance,
stamina_level: form.stamina_level, stamina_level: form.stamina_level,
@@ -518,7 +583,25 @@ function calculatePublishRatio() {
const baseRatio = getInsuranceBaseRatio(form.season_insurance); const baseRatio = getInsuranceBaseRatio(form.season_insurance);
if (baseRatio <= 0) return 0; if (baseRatio <= 0) return 0;
return baseRatio + calculateConfigPenalty() + getCoinCorrection(coinMAmount.value); return (
baseRatio +
calculateConfigPenalty() +
getCoinCorrection(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 calculateSalePricing() { function calculateSalePricing() {
@@ -535,7 +618,6 @@ function calculateSalePricing() {
coinBasePrice, coinBasePrice,
saleRatio: calculateEffectiveRatio(coinBasePrice), saleRatio: calculateEffectiveRatio(coinBasePrice),
ruleType: "fixed_markup", ruleType: "fixed_markup",
ruleText: `${formatSaleRange(fixedRule)} 加价 ${formatNumber(fixedRule.markup_amount)}`,
}; };
} }
@@ -550,9 +632,6 @@ function calculateSalePricing() {
coinBasePrice: roundMoney(coinWanAmount.value / saleRatio), coinBasePrice: roundMoney(coinWanAmount.value / saleRatio),
saleRatio, saleRatio,
ruleType: ratioRule ? "ratio_subtract" : "none", ruleType: ratioRule ? "ratio_subtract" : "none",
ruleText: ratioRule
? `${formatSaleRange(ratioRule)} 出售比例=回收比例-${formatNumber(ratioSubtract)}`
: "未匹配出售加价规则,按回收比例计算",
}; };
} }
@@ -562,7 +641,6 @@ function emptySalePricing(recycleCoinBasePrice = 0) {
coinBasePrice: 0, coinBasePrice: 0,
saleRatio: 0, saleRatio: 0,
ruleType: "none", ruleType: "none",
ruleText: "",
}; };
} }
@@ -585,13 +663,7 @@ function isCoinInSaleRange(item: { min_m: number; max_m: number }) {
function calculateEffectiveRatio(price: number) { function calculateEffectiveRatio(price: number) {
if (price <= 0) return 0; if (price <= 0) return 0;
return Math.round((coinWanAmount.value / price) * 10) / 10; return roundRatio(coinWanAmount.value / price);
}
function formatSaleRange(item: { min_m: number; max_m: number }) {
const minM = formatNumber(Number(item.min_m || 0));
const maxM = Number(item.max_m || 0);
return maxM > 0 ? `${minM}M-${formatNumber(maxM)}M` : `${minM}M以上`;
} }
function getInsuranceBaseRatio(insurance: string) { function getInsuranceBaseRatio(insurance: string) {
@@ -641,6 +713,10 @@ function formatNumber(value: number) {
return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}`; 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) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } }) const response = (error as { response?: { data?: { message?: string } } })
@@ -997,6 +1073,37 @@ function readError(error: unknown, fallback: string) {
<span class="hint-label" :data-hint="priceConfig.deposit_placeholder" tabindex="0">押金</span> <span class="hint-label" :data-hint="priceConfig.deposit_placeholder" tabindex="0">押金</span>
</template> </template>
</van-field> </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>
<van-field
:model-value="form.accelerated_sale_ratio"
label="加速出售比例"
type="number"
:placeholder="acceleratedSaleRatioPlaceholder"
class="publish-field"
@update:model-value="handleAcceleratedSaleRatioInput"
@blur="clampAcceleratedSaleRatioInput"
/>
<p class="field-hint">
请根据实际需求合理设置感谢您的配合
</p>
<div class="result-grid"> <div class="result-grid">
<van-field <van-field
:model-value="calculatedRatioText" :model-value="calculatedRatioText"
@@ -1009,12 +1116,6 @@ function readError(error: unknown, fallback: string) {
<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> </template>
</van-field> </van-field>
<van-field
:model-value="calculatedSaleRuleText"
label="出售规则"
readonly
class="publish-field result-field muted-result-field"
/>
<van-field <van-field
:model-value="calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : ''" :model-value="calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : ''"
label="纯币基础价" label="纯币基础价"
@@ -1043,7 +1144,7 @@ function readError(error: unknown, fallback: string) {
/> />
</div> </div>
<p class="price-breakdown-hint"> <p class="price-breakdown-hint">
纯币基础价先按回收比例计算再套出售加价规则发布总价 = 纯币基础价 + 额外消耗品 发布总价 = 纯币基础价 + 额外消耗品加速比例仅影响纯币基础价
</p> </p>
<van-field <van-field