From 2ce3320d5f93223e58a99a0f2a09abed2fa7a7c1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 22 Oct 2026 20:25:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(systemconfig):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=B1=95=E7=A4=BA=E5=BC=80=E5=85=B3=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E9=80=89=E9=A1=B9=E7=BC=96=E7=A0=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/systemconfig/dto.go | 4 + .../modules/systemconfig/home_entries_test.go | 24 +++ .../modules/systemconfig/publish_options.go | 6 +- .../systemconfig/publish_options_test.go | 161 ++++++++++++++ .../modules/systemconfig/repository.go | 196 ++++++++++++++---- .../modules/systemconfig/service_home.go | 25 +++ .../modules/systemconfig/service_update.go | 7 + .../admin/views/AdminSystemConfigsView.css | 27 +++ .../admin/views/AdminSystemConfigsView.vue | 61 +++++- .../features/listings/api/homeConfig.spec.ts | 6 + .../src/features/listings/api/homeConfig.ts | 9 + .../src/features/listings/views/HomeView.vue | 49 ++++- .../listings/views/MobileHomeView.css | 8 + .../listings/views/MobileHomeView.vue | 44 +++- frontend/src/stores/mobileHomeCache.ts | 14 ++ frontend/src/stores/pcHomeCache.ts | 4 + 16 files changed, 591 insertions(+), 54 deletions(-) create mode 100644 backend/internal/modules/systemconfig/home_entries_test.go create mode 100644 backend/internal/modules/systemconfig/publish_options_test.go diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 4980fde..f4c0cb6 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -35,6 +35,8 @@ type HomeConfigDTO struct { PublishOptions PublishOptionsDTO `json:"publish_options"` AWRecycle AWRecycleConfig `json:"aw_recycle"` CooperationFeedback CooperationFeedbackConfig `json:"cooperation_feedback"` + // HomeEntriesEnabled 首页业务入口总开关:关闭后 PC/移动端同时隐藏回收与合作入口。 + HomeEntriesEnabled bool `json:"home_entries_enabled"` } // AWRecycleConfig 首页「AW炫彩回收」入口:点击展示可配置图片。 @@ -118,6 +120,8 @@ type HomeBannerHotspot struct { } type PublishOptionsDTO struct { + // SchemaVersion 发布选项结构版本;缺省 0 代表未版本化的历史配置。 + SchemaVersion int `json:"schema_version,omitempty"` NextSeasonPassEnabled bool `json:"next_season_pass_enabled"` ServerOptions []string `json:"server_options"` FaceOptions []string `json:"face_options"` diff --git a/backend/internal/modules/systemconfig/home_entries_test.go b/backend/internal/modules/systemconfig/home_entries_test.go new file mode 100644 index 0000000..03e4ac0 --- /dev/null +++ b/backend/internal/modules/systemconfig/home_entries_test.go @@ -0,0 +1,24 @@ +package systemconfig + +import "testing" + +func TestParseHomeEntriesEnabled(t *testing.T) { + cases := []struct { + value string + want bool + }{ + {value: "true", want: true}, + {value: " TRUE ", want: true}, + {value: "1", want: true}, + {value: "false", want: false}, + {value: "0", want: false}, + {value: "", want: false}, + {value: "unknown", want: false}, + } + + for _, item := range cases { + if got := parseHomeEntriesEnabled(item.value); got != item.want { + t.Fatalf("parseHomeEntriesEnabled(%q) = %v, want %v", item.value, got, item.want) + } + } +} diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index 4a5809f..eb8d1f0 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -2,7 +2,10 @@ package systemconfig import "encoding/json" -const publishOptionsConfigKey = "listing.publish_options" +const ( + publishOptionsConfigKey = "listing.publish_options" + publishOptionsSchemaVersion = 1 +) func defaultPublishOptionsConfigValue() string { raw, err := json.Marshal(DefaultPublishOptions()) @@ -14,6 +17,7 @@ func defaultPublishOptionsConfigValue() string { func DefaultPublishOptions() PublishOptionsDTO { return PublishOptionsDTO{ + SchemaVersion: publishOptionsSchemaVersion, NextSeasonPassEnabled: false, ServerOptions: []string{"QQ", "微信", "Steam"}, FaceOptions: []string{"是", "否"}, diff --git a/backend/internal/modules/systemconfig/publish_options_test.go b/backend/internal/modules/systemconfig/publish_options_test.go new file mode 100644 index 0000000..4683733 --- /dev/null +++ b/backend/internal/modules/systemconfig/publish_options_test.go @@ -0,0 +1,161 @@ +package systemconfig + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestDecodeCP1252Mojibake(t *testing.T) { + original := `{"label":"6级子弹","price":"0.26元/发","punct":"(:,)"}` + mojibake := encodeCP1252Mojibake(original) + + decoded, ok := decodeCP1252Mojibake(mojibake) + if !ok { + t.Fatal("期望识别出 CP1252 乱码") + } + if decoded != original { + t.Fatalf("还原结果 = %q, want %q", decoded, original) + } + if _, ok := decodeCP1252Mojibake(original); ok { + t.Fatal("正常中文不应被误判为乱码") + } + if _, ok := decodeCP1252Mojibake("plain ascii"); ok { + t.Fatal("纯 ASCII 不应被误判为乱码") + } +} + +func TestRepairMojibakeJSONRepairsIndividualFields(t *testing.T) { + raw := `{"normal":"正常字段","broken":"` + encodeCP1252Mojibake("6级子弹") + `"}` + repaired := repairMojibakeJSON(raw) + + var payload map[string]string + if err := json.Unmarshal([]byte(repaired), &payload); err != nil { + t.Fatalf("还原后的 JSON 无法解析: %v", err) + } + if payload["normal"] != "正常字段" { + t.Fatalf("正常字段被破坏: %q", payload["normal"]) + } + if payload["broken"] != "6级子弹" { + t.Fatalf("乱码字段未还原: %q", payload["broken"]) + } +} + +func TestNormalizePublishOptionsValueMigratesLegacyQuantityItems(t *testing.T) { + legacy := `{ + "server_options": ["QQ"], + "quantity_items": [ + {"key": "kit5", "label": "5级全装套数量", "price": "5元/个"}, + {"key": "level6Ammo", "label": "6级子弹", "price": "0.26元/发"} + ] + }` + + normalized, err := normalizePublishOptionsValue(legacy) + if err != nil { + t.Fatalf("规范化失败: %v", err) + } + var options PublishOptionsDTO + if err := json.Unmarshal([]byte(normalized), &options); err != nil { + t.Fatalf("解析规范化结果失败: %v", err) + } + if options.SchemaVersion != publishOptionsSchemaVersion { + t.Fatalf("schema_version = %d, want %d", options.SchemaVersion, publishOptionsSchemaVersion) + } + for _, item := range options.QuantityItems { + if item.Key == "kit5" { + t.Fatal("旧版 kit5 选项应被迁移掉") + } + } + if !hasQuantityItemKey(options.QuantityItems, "level6Ammo") { + t.Fatal("迁移后的默认选项缺少 level6Ammo") + } +} + +func TestNormalizePublishOptionsValuePreservesCurrentSchema(t *testing.T) { + options := DefaultPublishOptions() + for index := range options.ScreenshotSlots { + if options.ScreenshotSlots[index].Key == "tencentSecurity" { + options.ScreenshotSlots[index].Required = true + } + } + options.QuantityItems = append(options.QuantityItems, PublishQuantityItem{ + Key: "kit5", + Label: "自定义旧选项", + Price: "9元/个", + }) + 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 !hasQuantityItemKey(got.QuantityItems, "kit5") { + t.Fatal("当前版本配置中的自定义项不应被旧版迁移清掉") + } + for _, slot := range got.ScreenshotSlots { + if slot.Key == "tencentSecurity" && !slot.Required { + t.Fatal("当前版本配置中 tencentSecurity.required=true 不应被覆盖") + } + } +} + +func TestNormalizePublishOptionsValueRepairsMojibake(t *testing.T) { + options := DefaultPublishOptions() + raw, err := json.Marshal(options) + if err != nil { + t.Fatalf("构造配置失败: %v", err) + } + mojibake := encodeCP1252Mojibake(string(raw)) + + normalized, err := normalizePublishOptionsValue(mojibake) + if err != nil { + t.Fatalf("规范化乱码配置失败: %v", err) + } + var got PublishOptionsDTO + if err := json.Unmarshal([]byte(normalized), &got); err != nil { + t.Fatalf("解析规范化结果失败: %v", err) + } + if !containsString(got.ServerOptions, "微信") { + t.Fatalf("乱码未恢复,server_options = %#v", got.ServerOptions) + } + if got.SchemaVersion != publishOptionsSchemaVersion { + t.Fatalf("schema_version = %d, want %d", got.SchemaVersion, publishOptionsSchemaVersion) + } +} + +func encodeCP1252Mojibake(value string) string { + var builder strings.Builder + for _, item := range []byte(value) { + if item >= 0x80 && item <= 0x9F { + builder.WriteRune(cp1252HighRunes[item-0x80]) + continue + } + builder.WriteRune(rune(item)) + } + return builder.String() +} + +func hasQuantityItemKey(items []PublishQuantityItem, key string) bool { + for _, item := range items { + if item.Key == key { + return true + } + } + return false +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 492c704..90fe249 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "sync" + "unicode/utf8" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" @@ -54,6 +55,7 @@ var defaultConfigs = []defaultConfig{ {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"}, {Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"}, + {Key: homeEntriesEnabledConfigKey, Value: defaultHomeEntriesEnabledConfigValue(), Description: "首页 AW炫彩回收/合作与反馈入口总开关(关闭后 PC/移动端均隐藏)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, {Key: DistributionSettlementHoldDaysKey, Value: defaultDistributionSettlementHoldDaysValue(), Description: "新建分销站点默认结算冷却天数(0=立即释放,最大 365)"}, @@ -84,6 +86,7 @@ var adminVisibleConfigKeys = []string{ "mobile.home_banners", "mobile.aw_recycle", "mobile.cooperation_feedback", + "mobile.home_entries_enabled", "order.agreements", "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", @@ -288,30 +291,14 @@ func updateLegacyPublishOptions(tx *gorm.DB, item defaultConfig) error { if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil { return err } - if isLegacyPublishOptionsValue(row.Value) { - row.Value = item.Value - row.Description = item.Description - return tx.Save(&row).Error - } - var options PublishOptionsDTO - if err := json.Unmarshal([]byte(row.Value), &options); err != nil { - row.Value = item.Value - row.Description = item.Description - return tx.Save(&row).Error - } - before := row.Value - isOldPublishOptionsSchema := !strings.Contains(before, `"ban_record_options"`) - normalizePublishOptions(&options) - if isOldPublishOptionsSchema { - applyConditionalBanEvidenceDefault(&options) - } - raw, err := json.Marshal(options) + normalized, err := normalizePublishOptionsValue(row.Value) if err != nil { - return err + normalized = item.Value } - row.Value = string(raw) + changed := row.Value != normalized || row.Description != item.Description + row.Value = normalized row.Description = item.Description - if row.Value == before { + if !changed { return nil } return tx.Save(&row).Error @@ -333,41 +320,164 @@ func applyConditionalBanEvidenceDefault(options *PublishOptionsDTO) { } } -func isLegacyPublishOptionsValue(value string) bool { - legacyMarkers := []string{ - `"kit5"`, - "5级全装套", - "6级子弹", - "6头数量", - "6甲数量", - "0.26元/发", - "0.8元/发", - "çº", - "å¼", - "å…", - "å¤", - "ä½", - "é«", - "ï¼", +func normalizePublishOptionsValue(value string) (string, error) { + value = repairMojibakeJSON(value) + var options PublishOptionsDTO + if err := json.Unmarshal([]byte(value), &options); err != nil { + return "", err } - for _, marker := range legacyMarkers { - if strings.Contains(value, marker) { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(value), &fields); err != nil { + return "", err + } + if options.SchemaVersion < publishOptionsSchemaVersion { + if hasLegacyPublishQuantityItem(options.QuantityItems) { + options = DefaultPublishOptions() + } else { + normalizePublishOptions(&options) + if _, ok := fields["ban_record_options"]; !ok { + applyConditionalBanEvidenceDefault(&options) + } + } + } else { + normalizePublishOptions(&options) + } + options.SchemaVersion = publishOptionsSchemaVersion + raw, err := json.Marshal(options) + if err != nil { + return "", err + } + return string(raw), nil +} + +func hasLegacyPublishQuantityItem(items []PublishQuantityItem) bool { + for _, item := range items { + if item.Key == "kit5" { return true } } return false } -func isMojibakeText(value string) bool { - mojibakeMarkers := []string{"å", "æ", "ç", "è", "é", "ä", "ï¼", "ã€", "â"} - for _, marker := range mojibakeMarkers { - if strings.Contains(value, marker) { +// repairMojibakeJSON 先按整段 JSON 还原;整段不是纯乱码时,再逐个字符串字段尝试还原。 +func repairMojibakeJSON(value string) string { + if decoded, ok := decodeCP1252Mojibake(value); ok { + return decoded + } + var root any + if err := json.Unmarshal([]byte(value), &root); err != nil { + return value + } + repaired, changed := repairMojibakeValue(root) + if !changed { + return value + } + raw, err := json.Marshal(repaired) + if err != nil { + return value + } + return string(raw) +} + +func repairMojibakeValue(value any) (any, bool) { + switch current := value.(type) { + case string: + if decoded, ok := decodeCP1252Mojibake(current); ok { + return decoded, true + } + return current, false + case []any: + changed := false + for index := range current { + next, updated := repairMojibakeValue(current[index]) + if updated { + current[index] = next + changed = true + } + } + return current, changed + case map[string]any: + changed := false + for key, item := range current { + next, updated := repairMojibakeValue(item) + if updated { + current[key] = next + changed = true + } + } + return current, changed + default: + return value, false + } +} + +// decodeCP1252Mojibake 把「UTF-8 字节被当作 CP1252/Latin-1 字符」的文本还原为原始 UTF-8。 +// 普通中文包含 CP1252 无法表示的字符,会直接判定失败;纯 ASCII 也不会误判。 +func decodeCP1252Mojibake(value string) (string, bool) { + if value == "" { + return "", false + } + raw := make([]byte, 0, len(value)) + sawNonASCII := false + for _, char := range value { + next, ok := cp1252Byte(char) + if !ok { + return "", false + } + if next >= 0x80 { + sawNonASCII = true + } + raw = append(raw, next) + } + if !sawNonASCII || !utf8.Valid(raw) { + return "", false + } + decoded := string(raw) + if decoded == value { + return "", false + } + // 只接受能还原出汉字的候选,避免把正常的西文重音字符误判为乱码。 + if !containsHan(decoded) { + return "", false + } + return decoded, true +} + +func containsHan(value string) bool { + for _, char := range value { + if char >= 0x4E00 && char <= 0x9FFF { return true } } return false } +// cp1252HighRunes 是 CP1252 中 0x80-0x9F 对应的 Unicode 字符; +// 其余可映射字符与 Latin-1 一致。 +var cp1252HighRunes = [32]rune{ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, + 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +} + +func cp1252Byte(char rune) (byte, bool) { + if char <= 0xFF { + return byte(char), true + } + for offset, candidate := range cp1252HighRunes { + if char == candidate { + return byte(0x80 + offset), true + } + } + return 0, false +} + +func isMojibakeText(value string) bool { + _, ok := decodeCP1252Mojibake(value) + return ok +} + func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", diff --git a/backend/internal/modules/systemconfig/service_home.go b/backend/internal/modules/systemconfig/service_home.go index a07f5d1..dd687a5 100644 --- a/backend/internal/modules/systemconfig/service_home.go +++ b/backend/internal/modules/systemconfig/service_home.go @@ -3,11 +3,18 @@ package systemconfig import ( "context" "encoding/json" + "strconv" "strings" "hfb_sys/backend/internal/model" ) +const homeEntriesEnabledConfigKey = "mobile.home_entries_enabled" + +func defaultHomeEntriesEnabledConfigValue() string { + return "false" +} + func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable @@ -43,12 +50,17 @@ func (s *Service) HomeConfig(ctx context.Context, siteCode string) (*HomeConfigD if err != nil { return nil, err } + homeEntriesEnabled, err := s.homeEntriesEnabled(ctx) + if err != nil { + return nil, err + } config := &HomeConfigDTO{ Announcements: announcements, Banners: banners, PublishOptions: publishOptions, AWRecycle: awRecycle, CooperationFeedback: cooperationFeedback, + HomeEntriesEnabled: homeEntriesEnabled, } s.applySiteHomeOverrides(ctx, siteCode, config) return config, nil @@ -147,3 +159,16 @@ func (s *Service) cooperationFeedback(ctx context.Context) (CooperationFeedbackC } return normalizeCooperationFeedback(item), nil } + +func (s *Service) homeEntriesEnabled(ctx context.Context) (bool, error) { + value, err := s.repo.FindValue(ctx, homeEntriesEnabledConfigKey) + if err != nil { + return false, err + } + return parseHomeEntriesEnabled(value), nil +} + +func parseHomeEntriesEnabled(value string) bool { + enabled, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && enabled +} diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 165cbb0..657060c 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -26,6 +26,13 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up return nil, ErrInvalidConfig } } + if key == publishOptionsConfigKey { + value, err := normalizePublishOptionsValue(req.Value) + if err != nil { + return nil, ErrInvalidConfig + } + req.Value = value + } if key == rentergrowth.ConfigKey { cfg, err := rentergrowth.ParseConfigValue(req.Value) if err != nil { diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.css b/frontend/src/features/admin/views/AdminSystemConfigsView.css index 99f3d4c..4d03b55 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.css +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.css @@ -38,6 +38,33 @@ gap: 10px; } +.home-entry-switch-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid #e5edf8; + border-radius: 8px; + background: #f8fbff; +} + +.home-entry-switch-row > div { + display: grid; + gap: 4px; +} + +.home-entry-switch-row strong { + color: #111827; + font-size: 14px; +} + +.home-entry-switch-row span { + color: #6b7280; + font-size: 12px; + line-height: 1.5; +} + .welcome-config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 9750d02..2cf5cdf 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -17,15 +17,21 @@ import { type HomeBannerSlide, } from '@/features/listings/api/homeConfig' import type { OrderAgreements } from '@/features/orders/api/orders' -import { fetchSystemConfigs, type SystemConfig } from '@/features/admin/api/systemConfigs' +import { + fetchSystemConfigs, + updateSystemConfig, + type SystemConfig, +} from '@/features/admin/api/systemConfigs' import { parseRenterGrowthConfig } from '@/features/admin/api/renterGrowth' import { parseSmsBusinessTemplatesConfig, smsNodeMetas, } from '@/features/admin/api/smsNodeTemplates' import { formatDateTime } from '@/shared/utils/time' +import { readError } from '@/shared/utils/error' import { safeParseJSON } from '@/shared/utils/json' import { formatSystemConfigSelectValue } from '@/shared/utils/systemConfigOptions' +import { ElMessage } from 'element-plus' // 导入重构后的 Dialog 子组件 import PublishOptionsDialog from '../components/PublishOptionsDialog.vue' @@ -120,6 +126,14 @@ const awRecycleConfig = computed( const cooperationFeedbackConfig = computed( () => configs.value.find(item => item.key === 'mobile.cooperation_feedback') || null ) +const homeEntriesEnabledConfig = computed( + () => configs.value.find(item => item.key === 'mobile.home_entries_enabled') || null +) +const homeEntriesDisplayEnabled = computed(() => { + const raw = homeEntriesEnabledConfig.value?.value.trim().toLowerCase() || '' + return raw === 'true' || raw === '1' +}) +const homeEntriesSwitchSaving = ref(false) const listingPublishAgreementsConfig = computed( () => configs.value.find(item => item.key === 'listing.publish_agreements') || null ) @@ -167,6 +181,7 @@ const regularConfigs = computed(() => item.key !== 'mobile.home_banners' && item.key !== 'mobile.aw_recycle' && item.key !== 'mobile.cooperation_feedback' && + item.key !== 'mobile.home_entries_enabled' && item.key !== 'listing.publish_agreements' && item.key !== 'order.agreements' && item.key !== 'profile.post_rental_notice' && @@ -216,6 +231,7 @@ const homeStats = computed(() => { const awRecycleStats = computed(() => { const item = parseAWRecycle(awRecycleConfig.value?.value || '') + if (!homeEntriesDisplayEnabled.value) return { enabledLabel: '已隐藏' } return { enabledLabel: item.enabled ? (item.image_url ? '已配置图' : '已开启') : '已隐藏', } @@ -223,6 +239,7 @@ const awRecycleStats = computed(() => { const cooperationFeedbackStats = computed(() => { const item = parseCooperationFeedback(cooperationFeedbackConfig.value?.value || '') + if (!homeEntriesDisplayEnabled.value) return { enabledLabel: '已隐藏' } return { enabledLabel: item.enabled ? (item.image_url ? '已配置图' : '已开启') : '已隐藏', } @@ -288,6 +305,25 @@ async function loadConfigs() { } } +async function handleHomeEntriesDisplayChange(value: string | number | boolean) { + const row = homeEntriesEnabledConfig.value + if (!row || homeEntriesSwitchSaving.value) return + const next = value === true || value === 1 || value === 'true' + homeEntriesSwitchSaving.value = true + try { + await updateSystemConfig(row.key, { + value: next ? 'true' : 'false', + description: row.description, + }) + await loadConfigs() + ElMessage.success(next ? '首页业务入口已恢复展示' : '首页业务入口已隐藏') + } catch (error) { + ElMessage.error(readError(error, '保存失败')) + } finally { + homeEntriesSwitchSaving.value = false + } +} + function systemConfigByKey(key: string, fallbackValue: string, description: string): SystemConfig { const existing = configs.value.find(item => item.key === key) if (existing) return existing @@ -654,7 +690,11 @@ function formatConfigValue(row: SystemConfig) {
@@ -685,6 +725,23 @@ function formatConfigValue(row: SystemConfig) { > +
+
+ 首页业务入口展示 + 总开关关闭后,PC + 与移动端同时隐藏「AW炫彩回收」「合作与反馈」;开启后按各入口自己的启用状态展示。 +
+ +
{{ homeStats.announcementCount }} diff --git a/frontend/src/features/listings/api/homeConfig.spec.ts b/frontend/src/features/listings/api/homeConfig.spec.ts index b6f8d24..66198c8 100644 --- a/frontend/src/features/listings/api/homeConfig.spec.ts +++ b/frontend/src/features/listings/api/homeConfig.spec.ts @@ -129,6 +129,12 @@ describe('mergeHomeConfig', () => { expect(config.banners.length).toBeGreaterThan(0) expect(config.banners[0]?.pc.image_url).toBe('') }) + + it('首页业务入口总开关缺省关闭,显式开启时透传', () => { + expect(mergeHomeConfig({}).home_entries_enabled).toBe(false) + expect(mergeHomeConfig({ home_entries_enabled: true }).home_entries_enabled).toBe(true) + expect(mergeHomeConfig({ home_entries_enabled: false }).home_entries_enabled).toBe(false) + }) }) describe('createBannerHotspot', () => { diff --git a/frontend/src/features/listings/api/homeConfig.ts b/frontend/src/features/listings/api/homeConfig.ts index df88a2a..cd1323e 100644 --- a/frontend/src/features/listings/api/homeConfig.ts +++ b/frontend/src/features/listings/api/homeConfig.ts @@ -209,8 +209,13 @@ export interface MobileHomeConfig { publish_options: ListingPublishOptions aw_recycle: AWRecycleConfig cooperation_feedback: CooperationFeedbackConfig + /** 首页业务入口总开关:关闭后 PC/移动端同时隐藏回收与合作入口。 */ + home_entries_enabled: boolean } +/** 总开关默认关闭,避免新环境误展示尚未准备好的业务入口。 */ +export const defaultHomeEntriesEnabled = false + export const defaultAWRecycleConfig: AWRecycleConfig = { title: 'AW炫彩回收', subtitle: '点击查看回收说明', @@ -320,6 +325,10 @@ export function mergeHomeConfig(config?: Partial): MobileHomeC publish_options: mergeListingPublishOptions(config?.publish_options), aw_recycle: awRecycle, cooperation_feedback: cooperationFeedback, + home_entries_enabled: + typeof config?.home_entries_enabled === 'boolean' + ? config.home_entries_enabled + : defaultHomeEntriesEnabled, } } diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index b158bdf..baca4f2 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -7,6 +7,7 @@ import { defaultCooperationFeedbackConfig, defaultHomeAnnouncements, defaultHomeBanners, + defaultHomeEntriesEnabled, fetchMobileHomeConfig, normalizeHomeBanner, type AWRecycleConfig, @@ -71,6 +72,20 @@ const awRecycle = ref({ ...defaultAWRecycleConfig }) const awRecycleDialogVisible = ref(false) const cooperationFeedback = ref({ ...defaultCooperationFeedbackConfig }) const cooperationFeedbackDialogVisible = ref(false) +const homeEntriesEnabled = ref(defaultHomeEntriesEnabled) +const showAWRecycle = computed(() => homeEntriesEnabled.value && awRecycle.value.enabled) +const showCooperationFeedback = computed( + () => homeEntriesEnabled.value && cooperationFeedback.value.enabled +) +const showBizEntryRail = computed( + () => !siteStore.isDistribution || showAWRecycle.value || showCooperationFeedback.value +) +const bizEntryCount = computed( + () => + (siteStore.isDistribution ? 0 : 1) + + (showAWRecycle.value ? 1 : 0) + + (showCooperationFeedback.value ? 1 : 0) +) const publishOptions = ref(emptyListingPublishOptions) interface SortButtonItem { key: string @@ -251,12 +266,14 @@ async function loadHome() { banners.value = config.banners awRecycle.value = config.aw_recycle cooperationFeedback.value = config.cooperation_feedback + homeEntriesEnabled.value = config.home_entries_enabled publishOptions.value = config.publish_options } catch { announcements.value = defaultHomeAnnouncements banners.value = defaultHomeBanners awRecycle.value = { ...defaultAWRecycleConfig } cooperationFeedback.value = { ...defaultCooperationFeedbackConfig } + homeEntriesEnabled.value = defaultHomeEntriesEnabled publishOptions.value = emptyListingPublishOptions } finally { loading.value = false @@ -404,6 +421,7 @@ function restoreFromCache(): boolean { banners.value = pcHomeCache.banners.map(slide => normalizeHomeBanner(slide)) awRecycle.value = { ...pcHomeCache.awRecycle } cooperationFeedback.value = { ...pcHomeCache.cooperationFeedback } + homeEntriesEnabled.value = pcHomeCache.homeEntriesEnabled publishOptions.value = pcHomeCache.publishOptions } return true @@ -425,6 +443,7 @@ function saveToCache() { banners: banners.value, awRecycle: awRecycle.value, cooperationFeedback: cooperationFeedback.value, + homeEntriesEnabled: homeEntriesEnabled.value, publishOptions: publishOptions.value, }) pcHomeCache.saveScrollTop(window.scrollY) @@ -539,11 +558,16 @@ if (!restored) {
-
+
-
+
撞车 NEW @@ -552,7 +576,7 @@ if (!restored) {