diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 92f38b3..0880563 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -51,19 +51,20 @@ type ListingDTO struct { } type CreateRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description"` - ServerRegion string `json:"server_region" binding:"required"` - LoginPlatform string `json:"login_platform"` - RankLevel string `json:"rank_level"` - HafCoinAmount int64 `json:"haf_coin_amount"` - AssetSummary map[string]any `json:"asset_summary"` - ScreenshotURLS []string `json:"screenshot_urls"` - PriceCent int64 `json:"price_cent"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - IsPremium bool `json:"is_premium"` - CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"` - InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。 + Title string `json:"title" binding:"required"` + Description string `json:"description"` + ServerRegion string `json:"server_region" binding:"required"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + AssetSummary map[string]any `json:"asset_summary"` + ScreenshotURLS []string `json:"screenshot_urls"` + PriceCent int64 `json:"price_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + IsPremium bool `json:"is_premium"` + CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"` + InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。 + OwnerIsQualityOwner bool `json:"-"` // 服务端鉴权结果,用于校验优质号主专享比例。 AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"` AgreedSellerAgreement bool `json:"agreed_seller_agreement"` diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go index dfa3236..fa881af 100644 --- a/backend/internal/modules/listing/handler_error.go +++ b/backend/internal/modules/listing/handler_error.go @@ -59,6 +59,8 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "分销站点无效或已停用") case errors.Is(err, ErrPremiumOwnerRequired): response.Error(c, http.StatusForbidden, "quality_owner_required", "仅优质号主可以发布到极品专区") + case errors.Is(err, ErrQualityRatioPremiumOnly): + response.BadRequest(c, "优质号主专享比例仅可用于极品专区") case isUploadValidationError(err): response.BadRequest(c, err.Error()) case isFireLevelTooLow(err): diff --git a/backend/internal/modules/listing/quality_owner_entitlement_test.go b/backend/internal/modules/listing/quality_owner_entitlement_test.go index c8e5d9a..9902d7c 100644 --- a/backend/internal/modules/listing/quality_owner_entitlement_test.go +++ b/backend/internal/modules/listing/quality_owner_entitlement_test.go @@ -34,6 +34,9 @@ func TestApplyPublishEntitlementsPremiumInsurance(t *testing.T) { if req.InsuranceFeeBps != 400 { t.Fatalf("极品专区保险费率 = %d, want 400", req.InsuranceFeeBps) } + if !req.OwnerIsQualityOwner { + t.Fatal("优质号主发布请求未标记专享比例资格") + } ordinary := CreateRequest{CompensationPlan: CompensationPlanInsurance} if err := service.applyPublishEntitlements(t.Context(), owner.ID, &ordinary); err != nil { diff --git a/backend/internal/modules/listing/receive_pricing.go b/backend/internal/modules/listing/receive_pricing.go index 7f90e7f..38ffd50 100644 --- a/backend/internal/modules/listing/receive_pricing.go +++ b/backend/internal/modules/listing/receive_pricing.go @@ -49,7 +49,7 @@ func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRe return err } applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, receiveCostDelta, onDistributionSite) - return nil + return validateQualityOwnerPricingMode(req, onDistributionSite) } func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) { @@ -71,9 +71,16 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis if requestedRatio <= 0 { requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"]) } + minSaleRatio := referenceRatio + if req.OwnerIsQualityOwner { + minSaleRatio = roundRatio(referenceRatio - NormalizeQualityOwnerRatioDiscount(options.RatioConfig.QualityOwnerRatioDiscount)) + if minSaleRatio < minSiteReceiveRatio { + minSaleRatio = minSiteReceiveRatio + } + } costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta) ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta) - sellerRatio := clampSaleRatio(referenceRatio, requestedRatio) + sellerRatio := clampSaleRatio(referenceRatio, requestedRatio, minSaleRatio) // 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。 if onDistributionSite { sellerRatio = ApplySiteReceiveCostDelta(referenceRatio, costDelta) @@ -98,6 +105,10 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis breakdown["consumable_price"] = consumablePrice breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss) breakdown["accelerated_sale_ratio"] = sellerRatio + if sellerRatio < referenceRatio { + breakdown["quality_owner_ratio_discount"] = roundRatio(referenceRatio - sellerRatio) + breakdown["pricing_mode"] = "quality_owner_exclusive" + } if onDistributionSite { breakdown["site_receive_cost_delta"] = costDelta breakdown["pricing_model"] = "site_cost_decoupled_v1" @@ -114,6 +125,18 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis req.PriceCent = yuanToCent(pricing.BuyerTotalPrice) } +func validateQualityOwnerPricingMode(req *CreateRequest, onDistributionSite bool) error { + if req == nil || onDistributionSite || !req.OwnerIsQualityOwner || req.IsPremium { + return nil + } + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) < + readSummaryNumber(breakdown["seller_reference_ratio"]) { + return ErrQualityRatioPremiumOnly + } + return nil +} + // NormalizeSiteReceiveCostDelta 将分站收号压价点数限制在 [0, 20]。 // 点数越大,分站收号比例越高(相对主站参考比例),收号价越低;0 表示与主站同价收号。 func NormalizeSiteReceiveCostDelta(delta float64) float64 { @@ -136,6 +159,17 @@ func ApplySiteReceiveCostDelta(referenceRatio, delta float64) float64 { return siteRatio } +// NormalizeQualityOwnerRatioDiscount 限制优质号主专享比例下调值,默认 1、最大 10。 +func NormalizeQualityOwnerRatioDiscount(value float64) float64 { + if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + if value > maxAcceleratedSaleRatioIncrement { + return maxAcceleratedSaleRatioIncrement + } + return roundRatio(value) +} + // referenceSellerCoinBasePrice 计算主站参考比例下的号主基础价,用作买家定价锚点。 func referenceSellerCoinBasePrice(coinWan, referenceRatio float64) float64 { if referenceRatio <= 0 { @@ -182,7 +216,7 @@ func (s *Service) publishRatioOptions(ctx context.Context) (systemconfig.Publish if strings.TrimSpace(value) == "" { return options, nil } - var parsed systemconfig.PublishOptionsDTO + parsed := options if err := json.Unmarshal([]byte(value), &parsed); err != nil { return options, nil } @@ -225,13 +259,13 @@ func calculateSellerReferenceRatio(coinM float64, summary map[string]any, option calculateDailyLossRatioAdjustment(dailyLoss) } -func clampSaleRatio(referenceRatio, requestedRatio float64) float64 { +func clampSaleRatio(referenceRatio, requestedRatio, minRatio float64) float64 { maxRatio := roundRatio(referenceRatio + maxAcceleratedSaleRatioIncrement) if requestedRatio <= 0 { return referenceRatio } - if requestedRatio < referenceRatio { - return referenceRatio + if requestedRatio < minRatio { + return minRatio } if requestedRatio > maxRatio { return maxRatio diff --git a/backend/internal/modules/listing/receive_pricing_test.go b/backend/internal/modules/listing/receive_pricing_test.go index 4009f39..b3c44c5 100644 --- a/backend/internal/modules/listing/receive_pricing_test.go +++ b/backend/internal/modules/listing/receive_pricing_test.go @@ -1,6 +1,7 @@ package listing import ( + "errors" "testing" "hfb_sys/backend/internal/modules/systemconfig" @@ -81,6 +82,63 @@ func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t * } } +func TestApplyMainSiteReceivePricingAllowsQualityOwnerExclusiveRatio(t *testing.T) { + req := fullReceivePricingRequest(42) + req.OwnerIsQualityOwner = true + options := systemconfig.DefaultPublishOptions() + applyMainSiteReceivePricing(&req, options, defaultSalePriceConfig(), 0, 0, false) + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) != 42 { + t.Fatalf("quality owner seller_ratio = %v, want 42", breakdown["seller_ratio"]) + } + if readSummaryNumber(breakdown["quality_owner_ratio_discount"]) != 1 { + t.Fatalf( + "quality owner discount = %v, want 1", + breakdown["quality_owner_ratio_discount"], + ) + } + + ordinary := fullReceivePricingRequest(42) + applyMainSiteReceivePricing(&ordinary, options, defaultSalePriceConfig(), 0, 0, false) + ordinaryBreakdown := ensurePriceBreakdown(ordinary.AssetSummary) + if readSummaryNumber(ordinaryBreakdown["seller_ratio"]) != 43 { + t.Fatalf("ordinary seller_ratio = %v, want reference 43", ordinaryBreakdown["seller_ratio"]) + } +} + +func TestQualityOwnerExclusiveRatioUsesConfiguredDiscount(t *testing.T) { + req := fullReceivePricingRequest(40) + req.OwnerIsQualityOwner = true + options := systemconfig.DefaultPublishOptions() + options.RatioConfig.QualityOwnerRatioDiscount = 3 + applyMainSiteReceivePricing(&req, options, defaultSalePriceConfig(), 0, 0, false) + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) != 40 { + t.Fatalf("configured quality owner seller_ratio = %v, want 40", breakdown["seller_ratio"]) + } +} + +func TestQualityOwnerExclusiveRatioRequiresPremiumZone(t *testing.T) { + req := fullReceivePricingRequest(42) + req.OwnerIsQualityOwner = true + applyMainSiteReceivePricing( + &req, + systemconfig.DefaultPublishOptions(), + defaultSalePriceConfig(), + 0, + 0, + false, + ) + if err := validateQualityOwnerPricingMode(&req, false); !errors.Is(err, ErrQualityRatioPremiumOnly) { + t.Fatalf("error = %v, want ErrQualityRatioPremiumOnly", err) + } + + req.IsPremium = true + if err := validateQualityOwnerPricingMode(&req, false); err != nil { + t.Fatalf("极品专区发布不应拒绝优质号主专享比例: %v", err) + } +} + func TestDistributionReceiveCostDeltaZeroKeepsMainSitePrice(t *testing.T) { req := fullReceivePricingRequest(50) applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, true) @@ -146,10 +204,10 @@ func TestApplySiteReceiveRatioDeltaCapsAtMainSite(t *testing.T) { } func TestClampSaleRatioRejectsBelowReference(t *testing.T) { - if got := clampSaleRatio(43, 10); got != 43 { + if got := clampSaleRatio(43, 10, 43); got != 43 { t.Fatalf("clamp below reference = %v, want 43", got) } - if got := clampSaleRatio(43, 80); got != 53 { + if got := clampSaleRatio(43, 80, 43); got != 53 { t.Fatalf("clamp above max = %v, want 53", got) } } diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 1ecc367..8966156 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -34,6 +34,7 @@ var ( ErrSaleRatioOutOfRange = errors.New("sale ratio out of range") ErrDistributionSiteInvalid = errors.New("invalid distribution site") ErrPremiumOwnerRequired = errors.New("premium listing requires quality owner") + ErrQualityRatioPremiumOnly = errors.New("quality owner exclusive ratio requires premium zone") ) type Service struct { diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go index 178fb7e..fa8d7c5 100644 --- a/backend/internal/modules/listing/service_mutation.go +++ b/backend/internal/modules/listing/service_mutation.go @@ -18,6 +18,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, if len(siteCode) > 0 { effectiveSiteCode = strings.TrimSpace(siteCode[0]) } + if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil { + return nil, err + } delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, 0, effectiveSiteCode) if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil { return nil, err @@ -32,9 +35,6 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, if err := validateRequiredOnlineTime(req); err != nil { return nil, err } - if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil { - return nil, err - } cooldown, err := s.publishCooldown(ctx) if err != nil { return nil, err @@ -54,6 +54,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd if len(siteCode) > 0 { effectiveSiteCode = strings.TrimSpace(siteCode[0]) } + if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil { + return nil, err + } delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, id, effectiveSiteCode) if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil { return nil, err @@ -68,9 +71,6 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd if err := validateRequiredOnlineTime(req); err != nil { return nil, err } - if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil { - return nil, err - } reviewRequired, err := s.reviewRequired(ctx) if err != nil { return nil, err @@ -94,6 +94,7 @@ func (s *Service) applyPublishEntitlements(ctx context.Context, ownerID uint64, if req.IsPremium && !isQualityOwner { return ErrPremiumOwnerRequired } + req.OwnerIsQualityOwner = isQualityOwner req.InsuranceFeeBps = 0 if req.CompensationPlan != CompensationPlanInsurance { diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index b3da7fb..7687a77 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -182,9 +182,10 @@ type PublishDepositSkinGroupRule struct { } type PublishRatioConfig struct { - InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"` - ConfigItems []PublishRatioConfigItem `json:"config_items"` - CoinCorrections []PublishCoinCorrection `json:"coin_corrections"` + QualityOwnerRatioDiscount float64 `json:"quality_owner_ratio_discount"` + InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"` + ConfigItems []PublishRatioConfigItem `json:"config_items"` + CoinCorrections []PublishCoinCorrection `json:"coin_corrections"` } type PublishInsuranceBaseRatio struct { diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index 732e31c..fa057db 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -105,6 +105,7 @@ func DefaultPublishOptions() PublishOptionsDTO { }, }, RatioConfig: PublishRatioConfig{ + QualityOwnerRatioDiscount: 1, InsuranceBaseRatios: []PublishInsuranceBaseRatio{ {Insurance: "3*3", Ratio: 40}, {Insurance: "2*3", Ratio: 42}, diff --git a/backend/internal/modules/systemconfig/publish_options_test.go b/backend/internal/modules/systemconfig/publish_options_test.go index 6d321ce..fcbb1aa 100644 --- a/backend/internal/modules/systemconfig/publish_options_test.go +++ b/backend/internal/modules/systemconfig/publish_options_test.go @@ -106,6 +106,54 @@ func TestNormalizePublishOptionsValuePreservesCurrentSchema(t *testing.T) { } } +func TestNormalizePublishOptionsValueDefaultsQualityOwnerRatioDiscount(t *testing.T) { + legacy := `{ + "schema_version": 2, + "ratio_config": { + "insurance_base_ratios": [{"insurance": "3*3", "ratio": 40}] + } + }` + + normalized, err := normalizePublishOptionsValue(legacy) + if err != nil { + t.Fatalf("规范化失败: %v", err) + } + var got PublishOptionsDTO + if err := json.Unmarshal([]byte(normalized), &got); err != nil { + t.Fatalf("解析规范化结果失败: %v", err) + } + if got.RatioConfig.QualityOwnerRatioDiscount != 1 { + t.Fatalf( + "quality_owner_ratio_discount = %v, want 1", + got.RatioConfig.QualityOwnerRatioDiscount, + ) + } +} + +func TestNormalizePublishOptionsValuePreservesExplicitZeroQualityOwnerDiscount(t *testing.T) { + options := DefaultPublishOptions() + options.RatioConfig.QualityOwnerRatioDiscount = 0 + raw, err := json.Marshal(options) + if err != nil { + t.Fatalf("构造配置失败: %v", err) + } + + normalized, err := normalizePublishOptionsValue(string(raw)) + if err != nil { + t.Fatalf("规范化失败: %v", err) + } + var got PublishOptionsDTO + if err := json.Unmarshal([]byte(normalized), &got); err != nil { + t.Fatalf("解析规范化结果失败: %v", err) + } + if got.RatioConfig.QualityOwnerRatioDiscount != 0 { + t.Fatalf( + "quality_owner_ratio_discount = %v, want explicit 0", + got.RatioConfig.QualityOwnerRatioDiscount, + ) + } +} + func TestNormalizePublishOptionsValueMigratesSchemaV1Switches(t *testing.T) { legacy := `{"schema_version":1,"next_season_pass_enabled":false,"server_options":["QQ"]}` diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 3433df0..989ffad 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -333,6 +333,10 @@ func normalizePublishOptionsValue(value string) (string, error) { if err := json.Unmarshal([]byte(value), &fields); err != nil { return "", err } + if !hasNestedJSONField(fields, "ratio_config", "quality_owner_ratio_discount") { + options.RatioConfig.QualityOwnerRatioDiscount = + DefaultPublishOptions().RatioConfig.QualityOwnerRatioDiscount + } originalVersion := options.SchemaVersion if options.SchemaVersion < publishOptionsSchemaVersion { if hasLegacyPublishQuantityItem(options.QuantityItems) { @@ -360,6 +364,19 @@ func normalizePublishOptionsValue(value string) (string, error) { return string(raw), nil } +func hasNestedJSONField(fields map[string]json.RawMessage, parent, child string) bool { + raw, ok := fields[parent] + if !ok { + return false + } + var nested map[string]json.RawMessage + if err := json.Unmarshal(raw, &nested); err != nil { + return false + } + _, ok = nested[child] + return ok +} + func hasLegacyPublishQuantityItem(items []PublishQuantityItem) bool { for _, item := range items { if item.Key == "kit5" { diff --git a/frontend/src/features/admin/components/PublishOptionsDialog.vue b/frontend/src/features/admin/components/PublishOptionsDialog.vue index 9832746..26e4966 100644 --- a/frontend/src/features/admin/components/PublishOptionsDialog.vue +++ b/frontend/src/features/admin/components/PublishOptionsDialog.vue @@ -401,6 +401,17 @@ async function handleSave() {
比例计算配置
+ + + + 默认 1,表示优质号主可在基础比例上降低 1;普通号主不能使用该区间。 + +
保险基础比例 添加保险比例 @@ -635,6 +646,13 @@ async function handleSave() { font-size: 13px; } +.inline-config-hint { + margin-left: 10px; + color: #8b95a5; + font-size: 12px; + line-height: 1.5; +} + .skin-config-row { display: grid; grid-template-columns: 140px 180px minmax(0, 1fr) 72px; diff --git a/frontend/src/features/listings/api/listingOptions.ts b/frontend/src/features/listings/api/listingOptions.ts index 9e01305..af1564d 100644 --- a/frontend/src/features/listings/api/listingOptions.ts +++ b/frontend/src/features/listings/api/listingOptions.ts @@ -78,6 +78,7 @@ export interface PublishSaleRatioAdjustmentRule { } export interface PublishRatioConfig { + quality_owner_ratio_discount: number insurance_base_ratios: PublishInsuranceBaseRatio[] config_items: PublishRatioConfigItem[] coin_corrections: PublishCoinCorrection[] @@ -151,6 +152,7 @@ export const emptyListingPublishOptions: ListingPublishOptions = { ], }, ratio_config: { + quality_owner_ratio_discount: 1, insurance_base_ratios: [], config_items: [], coin_corrections: [], @@ -363,6 +365,10 @@ function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGr function normalizeRatioConfig(value?: unknown): PublishRatioConfig { const row = isRecord(value) ? value : {} return { + quality_owner_ratio_discount: Math.min( + 10, + Math.max(0, readNumber(row.quality_owner_ratio_discount ?? 1)) + ), insurance_base_ratios: normalizeInsuranceBaseRatios( Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : [] ), diff --git a/frontend/src/features/seller/composables/usePublishDraft.ts b/frontend/src/features/seller/composables/usePublishDraft.ts index 9939b21..efc3d15 100644 --- a/frontend/src/features/seller/composables/usePublishDraft.ts +++ b/frontend/src/features/seller/composables/usePublishDraft.ts @@ -37,6 +37,7 @@ export function buildPublishDraft(options: { quantityModes: Record screenshotFiles: Record selectedSkins: string[] + qualityOwnerRatioDefaultApplied: boolean }): PublishDraft { return { form: { @@ -47,6 +48,7 @@ export function buildPublishDraft(options: { quantityModes: { ...options.quantityModes }, screenshotFiles: cloneScreenshotFiles(options.screenshotFiles), selectedSkins: [...options.selectedSkins], + qualityOwnerRatioDefaultApplied: options.qualityOwnerRatioDefaultApplied, } } @@ -63,6 +65,10 @@ export function readPublishDraft(draftKey: string) { selectedSkins: Array.isArray(draft.selectedSkins) ? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string') : [], + qualityOwnerRatioDefaultApplied: + typeof draft.qualityOwnerRatioDefaultApplied === 'boolean' + ? draft.qualityOwnerRatioDefaultApplied + : undefined, } } catch { localStorage.removeItem(draftKey) diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index a2c3bd8..b430c1c 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -92,6 +92,7 @@ export function usePublishForm(options: UsePublishFormOptions) { const unlockDemulanEnabled = computed(() => publishOptions.value.unlock_demulan_enabled) const nextSeasonPassEnabled = computed(() => publishOptions.value.next_season_pass_enabled) const isQualityOwner = computed(() => qualityOwnerStatus.value?.active === true) + const qualityOwnerRatioDefaultApplied = ref(true) const insuranceFeeBps = computed(() => form.is_premium && isQualityOwner.value ? Number(qualityOwnerStatus.value?.premium_insurance_fee_bps || 400) @@ -110,7 +111,43 @@ export function usePublishForm(options: UsePublishFormOptions) { isDistribution, receiveRatioDelta, receiveCostDelta, + isQualityOwner, }) + const qualityOwnerSaleRatioSelected = computed( + () => + isQualityOwner.value && + pricing.qualityOwnerRatioDiscount.value > 0 && + pricing.qualityOwnerSaleRatio.value > 0 && + Math.abs(Number(form.accelerated_sale_ratio) - pricing.qualityOwnerSaleRatio.value) < 0.05 + ) + const isQualityOwnerExclusiveRatioActive = computed( + () => + isQualityOwner.value && + pricing.calculatedSellerReferenceRatio.value > 0 && + pricing.hasAcceleratedSaleRatioInput() && + Number(form.accelerated_sale_ratio) < pricing.calculatedSellerReferenceRatio.value + ) + + watch( + [isQualityOwner, pricing.qualityOwnerRatioDiscount, pricing.qualityOwnerSaleRatio, isEditMode], + ([owner, discount, ratio, editing]) => { + if (editing || !owner || discount <= 0 || !qualityOwnerRatioDefaultApplied.value) return + if (ratio <= 0) return + if (Number(form.accelerated_sale_ratio) !== ratio) { + form.accelerated_sale_ratio = ratio + } + }, + { immediate: true } + ) + watch( + [isQualityOwnerExclusiveRatioActive, () => form.is_premium], + ([exclusiveRatio, premium]) => { + if (exclusiveRatio && !premium) { + form.is_premium = true + } + }, + { immediate: true } + ) // ========== 上号方式互斥 ========== // 微信区账号只能扫码登录:选择微信区后,「账号密码」不可选,默认固定为「扫码」。 @@ -279,6 +316,7 @@ export function usePublishForm(options: UsePublishFormOptions) { quantityModes, screenshotFiles, selectedSkins: selectedSkins.value, + qualityOwnerRatioDefaultApplied: qualityOwnerRatioDefaultApplied.value, }) ) } @@ -304,6 +342,8 @@ export function usePublishForm(options: UsePublishFormOptions) { Object.assign(quantityModes, draft.quantityModes) Object.assign(screenshotFiles, draft.screenshotFiles) selectedSkins.value = draft.selectedSkins + qualityOwnerRatioDefaultApplied.value = + draft.qualityOwnerRatioDefaultApplied ?? (form.accelerated_sale_ratio === '' ? true : false) hydrateScreenshotPreviews() } @@ -368,6 +408,7 @@ export function usePublishForm(options: UsePublishFormOptions) { } selectedSkins.value = Object.values(skinGroups).flatMap(value => stringArray(value)) + qualityOwnerRatioDefaultApplied.value = false virtualAssetSaleAgreementChecked.value = true sellerAgreementChecked.value = true passwordAndDeviceConfirmed.value = true @@ -385,6 +426,7 @@ export function usePublishForm(options: UsePublishFormOptions) { sellerAgreementChecked.value = false passwordAndDeviceConfirmed.value = false activeUploadKey.value = 'coin' + qualityOwnerRatioDefaultApplied.value = true // Also re-initialize quantityValues to 0 for (const item of publishOptions.value.quantity_items) { @@ -588,6 +630,7 @@ export function usePublishForm(options: UsePublishFormOptions) { } function handleAcceleratedSaleRatioInput(value: string | number | undefined) { + qualityOwnerRatioDefaultApplied.value = false if (value === '' || value === undefined) { form.accelerated_sale_ratio = '' return @@ -645,21 +688,36 @@ export function usePublishForm(options: UsePublishFormOptions) { } form.accelerated_sale_ratio = roundRatio( Math.min( - Math.max(ratio, pricing.calculatedDefaultSaleRatio.value), + Math.max(ratio, pricing.minAcceleratedSaleRatio.value), pricing.maxAcceleratedSaleRatio.value ) ) } function useReferenceSaleRatio() { + qualityOwnerRatioDefaultApplied.value = false form.accelerated_sale_ratio = '' } function useMaxAcceleratedSaleRatio() { if (pricing.maxAcceleratedSaleRatio.value <= 0) return + qualityOwnerRatioDefaultApplied.value = false form.accelerated_sale_ratio = pricing.maxAcceleratedSaleRatio.value } + function useQualityOwnerSaleRatio() { + if ( + !isQualityOwner.value || + pricing.qualityOwnerRatioDiscount.value <= 0 || + pricing.qualityOwnerSaleRatio.value <= 0 + ) { + return + } + qualityOwnerRatioDefaultApplied.value = true + form.accelerated_sale_ratio = pricing.qualityOwnerSaleRatio.value + form.is_premium = true + } + async function handleSubmit() { normalizeOnlineTime() const error = validateForm() @@ -740,10 +798,12 @@ export function usePublishForm(options: UsePublishFormOptions) { const ratio = Number(form.accelerated_sale_ratio) if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' if ( - pricing.calculatedDefaultSaleRatio.value > 0 && - ratio < pricing.calculatedDefaultSaleRatio.value + pricing.minAcceleratedSaleRatio.value > 0 && + ratio < pricing.minAcceleratedSaleRatio.value ) { - return `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}` + return isQualityOwner.value && pricing.qualityOwnerRatioDiscount.value > 0 + ? `优质号主专享比例不能低于 1:${formatNumber(pricing.minAcceleratedSaleRatio.value)}` + : `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}` } if ( pricing.maxAcceleratedSaleRatio.value > 0 && @@ -779,6 +839,9 @@ export function usePublishForm(options: UsePublishFormOptions) { if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容' if (form.is_premium && !isQualityOwner.value) return '仅优质号主可以发布到极品专区' + if (isQualityOwnerExclusiveRatioActive.value && !form.is_premium) { + return '优质号主专享比例仅可用于极品专区' + } if (!virtualAssetSaleAgreementChecked.value || !sellerAgreementChecked.value) { return '请先阅读并勾选两份发布协议' } @@ -837,6 +900,16 @@ export function usePublishForm(options: UsePublishFormOptions) { buyer_ratio: pricing.calculatedPlatformPricing.value.buyerRatio, platform_markup_amount: pricing.calculatedPlatformPricing.value.platformMarkupAmount, platform_rule_type: pricing.calculatedPlatformPricing.value.ruleType, + quality_owner_ratio_discount: + pricing.calculatedRatio.value < pricing.calculatedSellerReferenceRatio.value + ? roundRatio( + pricing.calculatedSellerReferenceRatio.value - pricing.calculatedRatio.value + ) + : 0, + pricing_mode: + pricing.calculatedRatio.value < pricing.calculatedSellerReferenceRatio.value + ? 'quality_owner_exclusive' + : 'standard', }, season_insurance: form.season_insurance, stamina_level: form.stamina_level, @@ -955,6 +1028,9 @@ export function usePublishForm(options: UsePublishFormOptions) { publishAgreements, qualityOwnerStatus, isQualityOwner, + qualityOwnerRatioDefaultApplied, + qualityOwnerSaleRatioSelected, + isQualityOwnerExclusiveRatioActive, insuranceFeeBps, insuranceFeePercent, virtualAssetSaleAgreementChecked, @@ -1013,6 +1089,7 @@ export function usePublishForm(options: UsePublishFormOptions) { clampAcceleratedSaleRatioInput, useReferenceSaleRatio, useMaxAcceleratedSaleRatio, + useQualityOwnerSaleRatio, handleSubmit, validateForm, isScreenshotRequired, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.css b/frontend/src/features/seller/views/MobileSellerListingCreateView.css index cd111cd..632f093 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.css +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.css @@ -706,6 +706,19 @@ gap: 8px; } +.ratio-mode-grid.has-quality-owner .ratio-mode-btn:nth-of-type(1) { + order: 1; +} + +.ratio-mode-grid.has-quality-owner .ratio-mode-btn:nth-of-type(2) { + order: 3; + grid-column: 1 / -1; +} + +.ratio-mode-grid.has-quality-owner .ratio-mode-btn:nth-of-type(3) { + order: 2; +} + .ratio-mode-btn { display: grid; gap: 5px; @@ -737,6 +750,44 @@ line-height: 1.25; } +.ratio-mode-btn em { + width: max-content; + margin: 0 auto; + padding: 2px 7px; + border-radius: 9px; + background: #fff3d6; + color: #91600d; + font-size: 10px; + font-style: normal; + font-weight: 900; +} + +.ratio-mode-btn--quality { + border-color: #dfc98c; + background: linear-gradient(135deg, #fffaf0 0%, #faf6ff 100%); + text-align: left; +} + +.ratio-mode-btn--quality strong { + color: #7a4b0d; +} + +.ratio-mode-btn.ratio-mode-btn--quality.active { + border-color: #b7791f; + background: linear-gradient(135deg, #fff4d8 0%, #f5edff 100%); + box-shadow: 0 0 0 2px rgba(183, 121, 31, 0.12); + color: #7a4b0d; +} + +.ratio-mode-btn.ratio-mode-btn--quality.active em { + background: #b7791f; + color: #fff; +} + +.ratio-mode-btn.ratio-mode-btn--quality.active span { + color: #7a4b0d; +} + .ratio-mode-btn.active { border-color: #ff6a00; background: #fff7f0; @@ -880,6 +931,16 @@ background: #f8fafc; } +.premium-coverage-card.is-quality-owner { + border-color: #e6d7b5; + background: linear-gradient(145deg, #fffdf8 0%, #faf8ff 100%); +} + +.premium-coverage-card.is-premium-active { + border-color: #d4ad5e; + box-shadow: 0 8px 20px rgba(139, 92, 20, 0.08); +} + .premium-zone-row, .coverage-heading { display: flex; @@ -888,6 +949,34 @@ gap: 12px; } +.premium-zone-copy { + display: grid; + min-width: 0; + gap: 4px; +} + +.premium-zone-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.premium-zone-title em { + padding: 2px 7px; + border-radius: 9px; + background: #fff3d6; + color: #8b5a0d; + font-size: 9px; + font-style: normal; + font-weight: 900; +} + +.premium-zone-title em.is-locked { + background: #f1f3f6; + color: #7b8492; +} + .premium-zone-row strong, .coverage-heading strong { display: block; @@ -905,16 +994,46 @@ line-height: 1.45; } -.quality-owner-link { - justify-self: start; - padding: 0; - border: none; - background: transparent; - color: #7c3aed; - font-size: 12px; +.premium-zone-action { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 7px; +} + +.premium-zone-action > span { + margin: 0; + font-size: 10px; font-weight: 800; } +.premium-zone-action.is-active > span { + color: #6d35c7; +} + +.premium-zone-action :deep(.van-switch--on) { + background: #7c3aed; +} + +.premium-zone-action :deep(.van-switch--disabled) { + opacity: 0.78; +} + +.quality-owner-link { + display: inline-flex; + align-items: center; + justify-self: start; + gap: 4px; + min-height: 30px; + padding: 0 10px; + border: 1px solid #ddd0f2; + border-radius: 15px; + background: #faf5ff; + color: #6d35c7; + font-size: 12px; + font-weight: 900; +} + .coverage-option-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -939,6 +1058,32 @@ font-weight: 800; } +.coverage-option > .coverage-option-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: inherit; + font-size: inherit; + line-height: normal; +} + +.coverage-option .coverage-rate { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 8px; + background: #f0f2f5; + color: #505a68; + font-size: 10px; + font-style: normal; + font-weight: 900; +} + +.coverage-option .coverage-rate.is-insurance { + background: #f3ebff; + color: #6d35c7; +} + .coverage-option span { color: #858c96; font-size: 10px; @@ -952,6 +1097,13 @@ font-weight: 800; } +.coverage-option .coverage-special-rate { + width: max-content; + padding: 2px 6px; + border-radius: 8px; + background: #f3ebff; +} + .coverage-option.active { border-color: #7c3aed; background: #faf5ff; diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 4ac370a..80e9495 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -27,7 +27,12 @@ const { passwordAndDeviceConfirmed, canPublishAfterAgreements, isQualityOwner, + isQualityOwnerExclusiveRatioActive, + qualityOwnerSaleRatioSelected, qualityOwnerStatus, + qualityOwnerRatioDiscount, + qualityOwnerSaleRatio, + minAcceleratedSaleRatio, insuranceFeePercent, showBanRecordRiskHint, unlockDemulanEnabled, @@ -91,6 +96,7 @@ const { clampAcceleratedSaleRatioInput, useReferenceSaleRatio, useMaxAcceleratedSaleRatio, + useQualityOwnerSaleRatio, handleSubmit, isScreenshotRequired, } = usePublishForm({ @@ -732,7 +738,11 @@ const screenshotGuides: Record = { {{ saleRatioRangeText }}{{ isDistribution ? '。' : ',比例越高出租速度越快,自行选择。' }}

-
+
+
@@ -824,21 +853,46 @@ const screenshotGuides: Record = {

卖家价格 = 纯币基础价 + 额外消耗品。

-
+
-
- 极品专区 +
+
+ 极品专区 + 优质号主已认证 + 优质号主专享 +
{{ isQualityOwner - ? '优质号主专享曝光专区' - : `保证金 ¥${formatMoney( + ? '发布后获得首页极品专区曝光' + : `缴纳 ¥${formatMoney( (qualityOwnerStatus?.required_deposit_cent || 20000) / 100 - )}` + )} 保证金后可开通` }}
- +
+ {{ + isQualityOwnerExclusiveRatioActive + ? '专享比例已关联专区' + : form.is_premium + ? '已开启极品专区' + : isQualityOwner + ? '开启极品专区' + : '开通后可发布' + }} + +
@@ -860,7 +915,10 @@ const screenshotGuides: Record = { :class="{ active: form.compensation_plan === 'normal' }" @click="form.compensation_plan = 'normal'" > - 普通赔付 + + 普通赔付 + 0% + 租金 + 押金规则赔付

- 无忧租号保险在普通赔付基础上额外保障账号市场价值;选择后按号主实际到手租金扣除服务费。 + 无忧租号保险在普通赔付基础上增加账号市场价值保障,服务费按实际到手租金扣除。

diff --git a/frontend/src/features/seller/views/SellerListingCreateView.css b/frontend/src/features/seller/views/SellerListingCreateView.css index 020eea8..af91cf0 100644 --- a/frontend/src/features/seller/views/SellerListingCreateView.css +++ b/frontend/src/features/seller/views/SellerListingCreateView.css @@ -705,7 +705,7 @@ .ratio-mode-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } @@ -727,6 +727,50 @@ font-size: 12px; } +.ratio-mode-btn em { + width: max-content; + padding: 3px 7px; + border-radius: 10px; + background: #fff3d6; + color: #9a6700; + font-size: 10px; + font-style: normal; + font-weight: 900; + letter-spacing: 0; +} + +.ratio-mode-btn--quality { + position: relative; + overflow: hidden; + border-color: #dfc98c; + background: linear-gradient(135deg, #fffaf0 0%, #f8f3ff 100%); +} + +.ratio-mode-btn--quality strong { + color: #7a4b0d; +} + +.ratio-mode-btn--quality > * { + position: relative; + z-index: 1; +} + +.ratio-mode-btn--quality.active { + border-color: #b7791f; + background: linear-gradient(135deg, #fff4d8 0%, #f5edff 100%); + color: #7a4b0d; + box-shadow: 0 0 0 2px rgba(183, 121, 31, 0.12); +} + +.ratio-mode-btn--quality.active em { + background: #b7791f; + color: #fff; +} + +.ratio-mode-btn--quality.active span { + color: #7a4b0d; +} + .ratio-custom-card { display: flex; flex-direction: column; @@ -1022,6 +1066,16 @@ background: #f8fafc; } +.premium-coverage-panel.is-quality-owner { + border-color: #e6d7b5; + background: linear-gradient(145deg, #fffdf8 0%, #faf8ff 100%); +} + +.premium-coverage-panel.is-premium-active { + border-color: #d4ad5e; + box-shadow: 0 10px 28px rgba(139, 92, 20, 0.08); +} + .premium-zone-row, .coverage-heading { display: flex; @@ -1035,6 +1089,33 @@ min-width: 0; } +.premium-zone-copy { + display: grid; + gap: 5px; +} + +.premium-zone-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.premium-zone-title em { + padding: 3px 8px; + border-radius: 10px; + background: #fff3d6; + color: #8b5a0d; + font-size: 10px; + font-style: normal; + font-weight: 900; +} + +.premium-zone-title em.is-locked { + background: #f1f3f6; + color: #7b8492; +} + .premium-zone-row strong, .coverage-heading strong { display: block; @@ -1051,17 +1132,52 @@ line-height: 1.5; } -.quality-owner-link { - justify-self: start; - padding: 0; - border: none; - background: transparent; - color: #7c3aed; - font-size: 12px; +.premium-zone-action { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 9px; +} + +.premium-zone-action > span { font-weight: 800; +} + +.premium-zone-action.is-active > span { + color: #6d35c7; +} + +.premium-zone-action :deep(.el-switch.is-checked .el-switch__core) { + border-color: #7c3aed; + background: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} + +.premium-zone-action :deep(.el-switch.is-disabled) { + opacity: 0.78; +} + +.quality-owner-link { + display: inline-flex; + align-items: center; + justify-self: start; + gap: 4px; + min-height: 30px; + padding: 0 10px; + border: 1px solid #ddd0f2; + border-radius: 15px; + background: #faf5ff; + color: #6d35c7; + font-size: 12px; + font-weight: 900; cursor: pointer; } +.quality-owner-link:hover { + border-color: #c7acdf; + background: #f4ebff; +} + .coverage-option-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1086,6 +1202,33 @@ font-weight: 900; } +.coverage-option > .coverage-option-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: inherit; + font-size: inherit; + font-weight: inherit; + line-height: normal; +} + +.coverage-option .coverage-rate { + flex-shrink: 0; + padding: 3px 8px; + border-radius: 10px; + background: #f0f2f5; + color: #505a68; + font-size: 12px; + font-style: normal; + font-weight: 900; +} + +.coverage-option .coverage-rate.is-insurance { + background: #f3ebff; + color: #6d35c7; +} + .coverage-option span { color: #6b7280; font-size: 12px; @@ -1100,6 +1243,13 @@ font-weight: 800; } +.coverage-option .coverage-special-rate { + width: max-content; + padding: 3px 7px; + border-radius: 9px; + background: #f3ebff; +} + .coverage-option.active { border-color: #7c3aed; background: #faf5ff; diff --git a/frontend/src/features/seller/views/SellerListingCreateView.vue b/frontend/src/features/seller/views/SellerListingCreateView.vue index 62efac3..a637cb2 100644 --- a/frontend/src/features/seller/views/SellerListingCreateView.vue +++ b/frontend/src/features/seller/views/SellerListingCreateView.vue @@ -1,5 +1,6 @@