diff --git a/backend/internal/modules/file/handler.go b/backend/internal/modules/file/handler.go index b258f66..075be5d 100644 --- a/backend/internal/modules/file/handler.go +++ b/backend/internal/modules/file/handler.go @@ -70,7 +70,9 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) { !strings.HasPrefix(key, "avatar/") && !strings.HasPrefix(key, "payment-cert/") && !strings.HasPrefix(key, "announcement/") && - !strings.HasPrefix(key, "mohong/") && !strings.HasPrefix(key, "crash/") { + !strings.HasPrefix(key, "mohong/") && + !strings.HasPrefix(key, "crash/") && + !strings.HasPrefix(key, "aw-recycle/") { response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") return } diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index 4426ba2..a3a00a1 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -106,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string { func fileURLForScene(scene string, key string) string { fileURL := "/api/files/object?key=" + url.QueryEscape(key) - if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" { + if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" { fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) } return fileURL @@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string { func normalizeScene(scene string) string { scene = strings.TrimSpace(strings.ToLower(scene)) switch scene { - case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash": + case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle": return scene default: return "misc" diff --git a/backend/internal/modules/file/storage_test.go b/backend/internal/modules/file/storage_test.go index 5fb103c..386f32f 100644 --- a/backend/internal/modules/file/storage_test.go +++ b/backend/internal/modules/file/storage_test.go @@ -29,3 +29,14 @@ func TestNormalizeSceneAllowsAnnouncement(t *testing.T) { t.Fatalf("normalize announcement scene = %q", got) } } + +func TestAWRecycleSceneUsesPublicFileURL(t *testing.T) { + if got := normalizeScene("aw-recycle"); got != "aw-recycle" { + t.Fatalf("normalize aw-recycle scene = %q", got) + } + key := "aw-recycle/2026/07/19/example.webp" + got := fileURLForScene("aw-recycle", key) + if !strings.HasPrefix(got, "/api/public/files/object?") { + t.Fatalf("aw-recycle file url = %q, want public file endpoint", got) + } +} diff --git a/backend/internal/modules/systemconfig/aw_recycle.go b/backend/internal/modules/systemconfig/aw_recycle.go new file mode 100644 index 0000000..79cea41 --- /dev/null +++ b/backend/internal/modules/systemconfig/aw_recycle.go @@ -0,0 +1,43 @@ +package systemconfig + +import ( + "encoding/json" + "strings" +) + +const awRecycleConfigKey = "mobile.aw_recycle" + +func defaultAWRecycle() AWRecycleConfig { + return AWRecycleConfig{ + Title: "AW炫彩回收", + Subtitle: "点击查看回收说明", + ImageURL: "", + Enabled: true, + } +} + +func defaultAWRecycleConfigValue() string { + raw, err := json.Marshal(defaultAWRecycle()) + if err != nil { + return `{"title":"AW炫彩回收","subtitle":"点击查看回收说明","image_url":"","enabled":true}` + } + return string(raw) +} + +func normalizeAWRecycle(item AWRecycleConfig) AWRecycleConfig { + def := defaultAWRecycle() + title := strings.TrimSpace(item.Title) + if title == "" { + title = def.Title + } + subtitle := strings.TrimSpace(item.Subtitle) + if subtitle == "" { + subtitle = def.Subtitle + } + return AWRecycleConfig{ + Title: title, + Subtitle: subtitle, + ImageURL: strings.TrimSpace(item.ImageURL), + Enabled: item.Enabled, + } +} diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 769a934..d784fe4 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -25,6 +25,15 @@ type HomeConfigDTO struct { Announcements []string `json:"announcements"` Banners []HomeBannerItem `json:"banners"` PublishOptions PublishOptionsDTO `json:"publish_options"` + AWRecycle AWRecycleConfig `json:"aw_recycle"` +} + +// AWRecycleConfig 首页「AW炫彩回收」入口:点击展示可配置图片。 +type AWRecycleConfig struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ImageURL string `json:"image_url"` + Enabled bool `json:"enabled"` } type OrderAgreementsDTO struct { diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 82c1fd9..afd663d 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -45,6 +45,7 @@ var defaultConfigs = []defaultConfig{ {Key: "integration.paddle_ocr_model", Value: "PaddleOCR-VL-1.6", Description: "PaddleOCR 识别模型"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, + {Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, } @@ -67,6 +68,7 @@ var adminVisibleConfigKeys = []string{ "listing.sale_price_config", "mobile.home_announcements", "mobile.home_banners", + "mobile.aw_recycle", "order.agreements", "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", diff --git a/backend/internal/modules/systemconfig/service_home.go b/backend/internal/modules/systemconfig/service_home.go index 05a1d44..8f4fe2b 100644 --- a/backend/internal/modules/systemconfig/service_home.go +++ b/backend/internal/modules/systemconfig/service_home.go @@ -28,6 +28,10 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) { if err != nil { return nil, err } + awRecycle, err := s.awRecycle(ctx) + if err != nil { + return nil, err + } publishOptions, err := s.publishOptions(ctx) if err != nil { return nil, err @@ -36,6 +40,7 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) { Announcements: announcements, Banners: banners, PublishOptions: publishOptions, + AWRecycle: awRecycle, }, nil } @@ -62,3 +67,15 @@ func (s *Service) homeBanners(ctx context.Context) ([]HomeBannerItem, error) { } return normalizeHomeBanners(items), nil } + +func (s *Service) awRecycle(ctx context.Context) (AWRecycleConfig, error) { + value, err := s.repo.FindValue(ctx, awRecycleConfigKey) + if err != nil { + return defaultAWRecycle(), err + } + item := defaultAWRecycle() + if err := json.Unmarshal([]byte(value), &item); err != nil { + item = defaultAWRecycle() + } + return normalizeAWRecycle(item), nil +} diff --git a/frontend/src/features/admin/components/AwRecycleDialog.vue b/frontend/src/features/admin/components/AwRecycleDialog.vue new file mode 100644 index 0000000..cf2ecc5 --- /dev/null +++ b/frontend/src/features/admin/components/AwRecycleDialog.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index dfa90f5..482a82c 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -27,6 +27,11 @@ import PublishOptionsDialog from '../components/PublishOptionsDialog.vue' import SalePriceDialog from '../components/SalePriceDialog.vue' import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue' import HomeBannersDialog from '../components/HomeBannersDialog.vue' +import AwRecycleDialog from '../components/AwRecycleDialog.vue' +import { + defaultAWRecycleConfig, + type AWRecycleConfig, +} from '@/features/listings/api/homeConfig' import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue' import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue' import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue' @@ -70,6 +75,7 @@ const publishVisible = ref(false) const salePriceVisible = ref(false) const announcementsVisible = ref(false) const bannersVisible = ref(false) +const awRecycleVisible = ref(false) const listingPublishAgreementsVisible = ref(false) const agreementsVisible = ref(false) const postRentalNoticeVisible = ref(false) @@ -90,6 +96,9 @@ const homeAnnouncementsConfig = computed( const homeBannersConfig = computed( () => configs.value.find(item => item.key === 'mobile.home_banners') || null ) +const awRecycleConfig = computed( + () => configs.value.find(item => item.key === 'mobile.aw_recycle') || null +) const listingPublishAgreementsConfig = computed( () => configs.value.find(item => item.key === 'listing.publish_agreements') || null ) @@ -135,6 +144,7 @@ const regularConfigs = computed(() => item.key !== 'listing.publish_cooldown_minutes' && item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_banners' && + item.key !== 'mobile.aw_recycle' && item.key !== 'listing.publish_agreements' && item.key !== 'order.agreements' && item.key !== 'profile.post_rental_notice' && @@ -180,6 +190,13 @@ const homeStats = computed(() => { } }) +const awRecycleStats = computed(() => { + const item = parseAWRecycle(awRecycleConfig.value?.value || '') + return { + enabledLabel: item.enabled ? (item.image_url ? '已配置图' : '已开启') : '已隐藏', + } +}) + const agreementStats = computed(() => { const agreements = parseOrderAgreements(orderAgreementsConfig.value?.value || '') return { @@ -236,6 +253,8 @@ function openEdit(row: SystemConfig) { announcementsVisible.value = true } else if (row.key === 'mobile.home_banners') { bannersVisible.value = true + } else if (row.key === 'mobile.aw_recycle') { + awRecycleVisible.value = true } else if (row.key === 'listing.publish_agreements') { listingPublishAgreementsVisible.value = true } else if (row.key === 'order.agreements') { @@ -269,6 +288,22 @@ function parseHomeBanners(raw: string) { return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners) } +function parseAWRecycle(raw: string): AWRecycleConfig { + const parsed = safeParseJSON(raw, defaultAWRecycleConfig) + return { + title: + typeof parsed.title === 'string' && parsed.title.trim() + ? parsed.title.trim() + : defaultAWRecycleConfig.title, + subtitle: + typeof parsed.subtitle === 'string' && parsed.subtitle.trim() + ? parsed.subtitle.trim() + : defaultAWRecycleConfig.subtitle, + image_url: typeof parsed.image_url === 'string' ? parsed.image_url.trim() : '', + enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : true, + } +} + function parseOrderAgreements(raw: string) { const parsed = safeParseJSON(raw, defaultOrderAgreements) return { @@ -479,20 +514,26 @@ function formatConfigValue(row: SystemConfig) { -
+

Home Content

-

移动端首页运营配置

- 管理首页公告滚动内容和顶部轮播图,保存后移动端会从接口读取最新配置。 +

首页运营配置

+ 管理首页公告、轮播图与 AW炫彩回收入口展示图,保存后 PC/移动端从接口读取。
编辑公告 - 编辑轮播图 + 编辑 AW炫彩回收
@@ -504,17 +545,17 @@ function formatConfigValue(row: SystemConfig) { {{ homeStats.bannerCount }} 轮播图
+
+ {{ awRecycleStats.enabledLabel }} + AW炫彩回收 +
接口 /api/mobile-home-config
- 公告 - {{ formatHomeConfigStatus(homeAnnouncementsConfig, '未初始化') }} -
-
- 轮播 - {{ formatHomeConfigStatus(homeBannersConfig, '未初始化') }} + 更新 + {{ formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化') }}
@@ -767,6 +808,13 @@ function formatConfigValue(row: SystemConfig) { @saved="loadConfigs" /> + + ): MobileHomeC }) .filter(item => item.title || item.image_url) || [] + const awRaw = config?.aw_recycle + const awRecycle: AWRecycleConfig = { + title: + typeof awRaw?.title === 'string' && awRaw.title.trim() + ? awRaw.title.trim() + : defaultAWRecycleConfig.title, + subtitle: + typeof awRaw?.subtitle === 'string' && awRaw.subtitle.trim() + ? awRaw.subtitle.trim() + : defaultAWRecycleConfig.subtitle, + image_url: + typeof awRaw?.image_url === 'string' ? awRaw.image_url.trim() : defaultAWRecycleConfig.image_url, + enabled: typeof awRaw?.enabled === 'boolean' ? awRaw.enabled : defaultAWRecycleConfig.enabled, + } + return { announcements: announcements.length ? announcements : defaultHomeAnnouncements, banners: banners.length ? banners : defaultHomeBanners, publish_options: mergeListingPublishOptions(config?.publish_options), + aw_recycle: awRecycle, } } diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index bd91454..b223bf8 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -2,11 +2,15 @@ import { computed, ref, watch } from 'vue' import { RouterLink, useRoute, useRouter } from 'vue-router' import { + defaultAWRecycleConfig, defaultHomeAnnouncements, defaultHomeBanners, fetchMobileHomeConfig, + type AWRecycleConfig, type HomeBannerSlide, } from '@/features/listings/api/homeConfig' +import { ElMessage } from 'element-plus' +import AuthImage from '@/shared/components/business/AuthImage.vue' import { emptyListingPublishOptions, type ListingPublishOptions, @@ -37,6 +41,8 @@ import ListingCard from '../components/ListingCard.vue' const announcements = ref(defaultHomeAnnouncements) const banners = ref(defaultHomeBanners) +const awRecycle = ref({ ...defaultAWRecycleConfig }) +const awRecycleDialogVisible = ref(false) const publishOptions = ref(emptyListingPublishOptions) const sortBy = ref('recommended') const sortOptions = [ @@ -149,16 +155,26 @@ async function loadHome() { const [, config] = await Promise.all([loadListingsPage(true), fetchMobileHomeConfig()]) announcements.value = config.announcements banners.value = config.banners + awRecycle.value = config.aw_recycle publishOptions.value = config.publish_options } catch { announcements.value = defaultHomeAnnouncements banners.value = defaultHomeBanners + awRecycle.value = { ...defaultAWRecycleConfig } publishOptions.value = emptyListingPublishOptions } finally { loading.value = false } } +function openAWRecycle() { + if (!awRecycle.value.image_url) { + ElMessage.warning('回收说明图片尚未配置,请稍后再试') + return + } + awRecycleDialogVisible.value = true +} + function updateFilters(partial: Partial) { Object.assign(filters, partial) } @@ -275,8 +291,36 @@ loadHome() 撞车 NEW 选购商品 · 支付后进群联系客服 + + + + + + import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeRouteLeave, RouterLink, useRoute, useRouter } from 'vue-router' -import { showToast } from 'vant' import MobileBottomNav from '@/components/MobileBottomNav.vue' import { ensureSupportChat, resolveSupportScene } from '@/features/chats/api/chats' import { formatCent, formatMoney } from '@/shared/utils/money' @@ -15,11 +14,15 @@ import { type PublicListingQuery, } from '@/features/listings/api/listings' import { + defaultAWRecycleConfig, defaultHomeAnnouncements, defaultHomeBanners, fetchMobileHomeConfig, + type AWRecycleConfig, type HomeBannerSlide, } from '@/features/listings/api/homeConfig' +import { showImagePreview, showToast } from 'vant' +import { fetchFileBlobByURL } from '@/shared/api/files' import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue' import { clearStoredHomeQuery, @@ -76,6 +79,7 @@ const refreshing = ref(false) const searchValue = ref('') const announcements = ref(defaultHomeAnnouncements) const bannerSlides = ref(defaultHomeBanners) +const awRecycle = ref({ ...defaultAWRecycleConfig }) const supportLoading = ref(false) const showBackTop = ref(false) const mobilePageSize = 10 @@ -457,6 +461,7 @@ async function loadHomeConfig() { const config = await fetchMobileHomeConfig() announcements.value = config.announcements bannerSlides.value = config.banners + awRecycle.value = config.aw_recycle publishOptions.value = config.publish_options homeCache.saveHomeConfig({ announcements: config.announcements, @@ -466,10 +471,39 @@ async function loadHomeConfig() { } catch { announcements.value = defaultHomeAnnouncements bannerSlides.value = defaultHomeBanners + awRecycle.value = { ...defaultAWRecycleConfig } publishOptions.value = emptyListingPublishOptions } } +async function openAWRecycle() { + const source = awRecycle.value.image_url + if (!source) { + showToast({ message: '回收说明图片尚未配置', icon: 'warning-o' }) + return + } + try { + // 私有 /api/files/object 需带鉴权拉取;公开 /api/public/files 可直接预览 + let previewURL = source + if ( + source.includes('/api/files/object') || + source.includes('/api/admin/files/object') + ) { + const blob = await fetchFileBlobByURL(source) + previewURL = URL.createObjectURL(blob) + } + showImagePreview({ + images: [previewURL], + closeable: true, + }) + } catch { + showToast({ + message: '图片加载失败,请在后台重新上传(需使用公开图)', + icon: 'cross', + }) + } +} + async function onRefresh() { refreshing.value = true try { @@ -477,6 +511,7 @@ async function onRefresh() { const [, nextHomeConfig] = await Promise.all([loadListings(true), fetchMobileHomeConfig()]) announcements.value = nextHomeConfig.announcements bannerSlides.value = nextHomeConfig.banners + awRecycle.value = nextHomeConfig.aw_recycle publishOptions.value = nextHomeConfig.publish_options homeCache.saveHomeConfig({ announcements: nextHomeConfig.announcements, @@ -842,6 +877,15 @@ syncMobileHomeQuery() 选购商品 · 一键找客服 NEW +
diff --git a/frontend/src/shared/utils/imageUpload.ts b/frontend/src/shared/utils/imageUpload.ts index 855e340..3389926 100644 --- a/frontend/src/shared/utils/imageUpload.ts +++ b/frontend/src/shared/utils/imageUpload.ts @@ -3,6 +3,7 @@ const IMAGE_UPLOAD_MAX_SIDE: Record = { chat: 1280, announcement: 1600, 'home-banner': 1920, + 'aw-recycle': 1920, listing: 1920, dispute: 1920, handoff: 1920, @@ -15,6 +16,7 @@ const IMAGE_UPLOAD_QUALITY: Record = { chat: 0.8, announcement: 0.84, 'home-banner': 0.84, + 'aw-recycle': 0.84, listing: 0.84, dispute: 0.86, handoff: 0.86,