From c9db34ddda85f466f398d2a26dba9461a41b0773 Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 23 May 2026 01:50:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5=E5=85=AC?= =?UTF-8?q?=E5=91=8A=E4=B8=8E=E8=BD=AE=E6=92=AD=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/file/handler.go | 12 + backend/internal/modules/file/service.go | 8 +- backend/internal/modules/systemconfig/dto.go | 19 ++ .../internal/modules/systemconfig/handler.go | 18 ++ .../systemconfig/home_announcements.go | 59 ++++ .../modules/systemconfig/repository.go | 2 + .../internal/modules/systemconfig/service.go | 108 ++++++- backend/internal/router/router.go | 4 + frontend/src/api/files.ts | 10 + frontend/src/api/homeConfig.ts | 101 ++++++ .../views/admin/AdminSystemConfigsView.vue | 304 +++++++++++++++++- frontend/src/views/mobile/MobileHomeView.css | 108 +++++-- frontend/src/views/mobile/MobileHomeView.vue | 100 +++--- .../src/views/mobile/MobileProfileView.vue | 35 +- 14 files changed, 797 insertions(+), 91 deletions(-) create mode 100644 backend/internal/modules/systemconfig/home_announcements.go create mode 100644 frontend/src/api/homeConfig.ts diff --git a/backend/internal/modules/file/handler.go b/backend/internal/modules/file/handler.go index 5bcbdb2..7c9e1a1 100644 --- a/backend/internal/modules/file/handler.go +++ b/backend/internal/modules/file/handler.go @@ -48,6 +48,14 @@ func (h *Handler) Upload(c *gin.Context) { } func (h *Handler) Object(c *gin.Context) { + h.writeObject(c, false) +} + +func (h *Handler) PublicObject(c *gin.Context) { + h.writeObject(c, true) +} + +func (h *Handler) writeObject(c *gin.Context, publicOnly bool) { if h.storage == nil { response.ServiceUnavailable(c, "文件存储未连接") return @@ -57,6 +65,10 @@ func (h *Handler) Object(c *gin.Context) { response.BadRequest(c, "文件 key 不正确") return } + if publicOnly && !strings.HasPrefix(key, "home-banner/") { + response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") + return + } object, err := h.storage.Get(c.Request.Context(), key) if err != nil { response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index 67f79ae..af5aff4 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -48,9 +48,13 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { if err != nil { return nil, err } + url := "/api/files/object?key=" + key + if scene == "home-banner" { + url = "/api/public/files/object?key=" + key + } return &UploadDTO{ ObjectKey: key, - URL: "/api/files/object?key=" + key, + URL: url, Filename: req.Header.Filename, ContentType: contentType, Size: req.Header.Size, @@ -60,7 +64,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { func normalizeScene(scene string) string { scene = strings.TrimSpace(strings.ToLower(scene)) switch scene { - case "listing", "handoff", "dispute", "realname", "avatar": + case "listing", "handoff", "dispute", "realname", "avatar", "home-banner": return scene default: return "misc" diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 2973173..002a4b5 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -17,6 +17,25 @@ type UpdateRequest struct { Description string `json:"description"` } +type HomeAnnouncementsDTO struct { + Items []string `json:"items"` +} + +type HomeConfigDTO struct { + Announcements []string `json:"announcements"` + Banners []HomeBannerItem `json:"banners"` + PublishOptions PublishOptionsDTO `json:"publish_options"` +} + +type HomeBannerItem struct { + Eyebrow string `json:"eyebrow"` + Title string `json:"title"` + Badge string `json:"badge"` + Pill string `json:"pill"` + Tone string `json:"tone"` + ImageURL string `json:"image_url,omitempty"` +} + type PublishOptionsDTO struct { ServerOptions []string `json:"server_options"` FaceOptions []string `json:"face_options"` diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index 2ab6334..1c200e8 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -36,6 +36,24 @@ func (h *Handler) PublishOptions(c *gin.Context) { response.OK(c, options) } +func (h *Handler) HomeAnnouncements(c *gin.Context) { + announcements, err := h.service.HomeAnnouncements() + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, announcements) +} + +func (h *Handler) HomeConfig(c *gin.Context) { + config, err := h.service.HomeConfig() + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, config) +} + func (h *Handler) Update(c *gin.Context) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/systemconfig/home_announcements.go b/backend/internal/modules/systemconfig/home_announcements.go new file mode 100644 index 0000000..093a1b9 --- /dev/null +++ b/backend/internal/modules/systemconfig/home_announcements.go @@ -0,0 +1,59 @@ +package systemconfig + +import "encoding/json" + +const homeAnnouncementsConfigKey = "mobile.home_announcements" +const homeBannersConfigKey = "mobile.home_banners" + +func defaultHomeAnnouncements() []string { + return []string{ + "平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。", + "优先推荐同地区账号,减少异地登录保护触发。", + "下单前请核对哈夫币、保险、体力负重和截图信息。", + } +} + +func defaultHomeAnnouncementsConfigValue() string { + raw, err := json.Marshal(defaultHomeAnnouncements()) + if err != nil { + return "[]" + } + return string(raw) +} + +func defaultHomeBanners() []HomeBannerItem { + return []HomeBannerItem{ + { + Eyebrow: "三角洲行动账号专区", + Title: "高哈夫币 · 安全交接 · 随租随玩", + Badge: "HOT", + Pill: "新人领券最高减 20 元", + Tone: "blue", + ImageURL: "", + }, + { + Eyebrow: "平台担保交易", + Title: "交接全程留痕,拒绝私下转账", + Badge: "SAFE", + Pill: "租号前先验实名与资料", + Tone: "green", + ImageURL: "", + }, + { + Eyebrow: "高效筛选", + Title: "按区服、段位、哈夫币快速找号", + Badge: "FAST", + Pill: "支持扫码号与账密号", + Tone: "orange", + ImageURL: "", + }, + } +} + +func defaultHomeBannersConfigValue() string { + raw, err := json.Marshal(defaultHomeBanners()) + if err != nil { + return "[]" + } + return string(raw) +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 01e3f68..faf1451 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -38,6 +38,8 @@ var defaultConfigs = []defaultConfig{ {Key: "settlement.platform_fee_rate", Value: "0.00", Description: "平台抽成比例"}, {Key: "settlement.owner_cycle_days", Value: "0", Description: "号主结算周期天数"}, {Key: "withdraw.min_amount", Value: "100", Description: "提现最低金额预留"}, + {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, + {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, } diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index b4f6f1f..a7ec823 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -3,6 +3,7 @@ package systemconfig import ( "encoding/json" "errors" + "strings" ) var ( @@ -29,16 +30,82 @@ func (s *Service) PublishOptions() (*PublishOptionsDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - value, err := s.repo.FindValue(publishOptionsConfigKey) + options, err := s.publishOptions() if err != nil { return nil, err } + return &options, nil +} + +func (s *Service) publishOptions() (PublishOptionsDTO, error) { + value, err := s.repo.FindValue(publishOptionsConfigKey) + if err != nil { + return PublishOptionsDTO{}, err + } options := DefaultPublishOptions() if err := json.Unmarshal([]byte(value), &options); err != nil { options = DefaultPublishOptions() } normalizePublishOptions(&options) - return &options, nil + return options, nil +} + +func (s *Service) HomeAnnouncements() (*HomeAnnouncementsDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + items, err := s.homeAnnouncements() + if err != nil { + return nil, err + } + return &HomeAnnouncementsDTO{Items: items}, nil +} + +func (s *Service) HomeConfig() (*HomeConfigDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + announcements, err := s.homeAnnouncements() + if err != nil { + return nil, err + } + banners, err := s.homeBanners() + if err != nil { + return nil, err + } + publishOptions, err := s.publishOptions() + if err != nil { + return nil, err + } + return &HomeConfigDTO{ + Announcements: announcements, + Banners: banners, + PublishOptions: publishOptions, + }, nil +} + +func (s *Service) homeAnnouncements() ([]string, error) { + value, err := s.repo.FindValue(homeAnnouncementsConfigKey) + if err != nil { + return nil, err + } + items := defaultHomeAnnouncements() + if err := json.Unmarshal([]byte(value), &items); err != nil { + items = defaultHomeAnnouncements() + } + return normalizeAnnouncements(items), nil +} + +func (s *Service) homeBanners() ([]HomeBannerItem, error) { + value, err := s.repo.FindValue(homeBannersConfigKey) + if err != nil { + return nil, err + } + items := defaultHomeBanners() + if err := json.Unmarshal([]byte(value), &items); err != nil { + items = defaultHomeBanners() + } + return normalizeHomeBanners(items), nil } func (s *Service) Update(actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) { @@ -51,6 +118,43 @@ func (s *Service) Update(actorID uint64, key string, req UpdateRequest, meta Aud return s.repo.Update(actorID, key, req, meta) } +func normalizeAnnouncements(items []string) []string { + normalized := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + normalized = append(normalized, item) + } + } + if len(normalized) == 0 { + return defaultHomeAnnouncements() + } + return normalized +} + +func normalizeHomeBanners(items []HomeBannerItem) []HomeBannerItem { + normalized := make([]HomeBannerItem, 0, len(items)) + for _, item := range items { + item.Eyebrow = strings.TrimSpace(item.Eyebrow) + item.Title = strings.TrimSpace(item.Title) + item.Badge = strings.TrimSpace(item.Badge) + item.Pill = strings.TrimSpace(item.Pill) + item.Tone = strings.TrimSpace(item.Tone) + item.ImageURL = strings.TrimSpace(item.ImageURL) + if item.Title == "" && item.ImageURL == "" { + continue + } + if item.Tone != "green" && item.Tone != "orange" { + item.Tone = "blue" + } + normalized = append(normalized, item) + } + if len(normalized) == 0 { + return defaultHomeBanners() + } + return normalized +} + func normalizePublishOptions(options *PublishOptionsDTO) { defaults := DefaultPublishOptions() if len(options.ServerOptions) == 0 { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c3c1ba9..9218158 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -127,6 +127,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { api.GET("/health", health.Check) api.GET("/listing-publish-options", systemConfigHandler.PublishOptions) + api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements) + api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) + api.GET("/public/files/object", fileHandler.PublicObject) authRoutes := api.Group("/auth") { @@ -208,6 +211,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) + adminRoutes.POST("/files/upload", fileHandler.Upload) adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) adminRoutes.GET("/files/object", fileHandler.Object) adminRoutes.GET("/users", adminUserHandler.List) diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 91b5034..b5ccb57 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -24,6 +24,16 @@ export async function uploadFile(file: File, scene: string) { return data.data } +export async function uploadAdminFile(file: File, scene: string) { + const form = new FormData() + form.append('file', file) + form.append('scene', scene) + const { data } = await apiClient.post>('/admin/files/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data.data +} + export async function fetchAdminFileBlob(key: string) { const { data } = await apiClient.get('/admin/files/object', { params: { key }, diff --git a/frontend/src/api/homeConfig.ts b/frontend/src/api/homeConfig.ts new file mode 100644 index 0000000..b71c198 --- /dev/null +++ b/frontend/src/api/homeConfig.ts @@ -0,0 +1,101 @@ +import { apiClient } from './client' +import { + defaultListingPublishOptions, + mergeListingPublishOptions, + type ListingPublishOptions, +} from './listingOptions' + +export interface HomeBannerSlide { + eyebrow: string + title: string + badge: string + pill: string + tone: string + image_url?: string +} + +export interface MobileHomeConfig { + announcements: string[] + banners: HomeBannerSlide[] + publish_options: ListingPublishOptions +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export const defaultHomeAnnouncements = [ + '平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。', + '优先推荐同地区账号,减少异地登录保护触发。', + '下单前请核对哈夫币、保险、体力负重和截图信息。', +] + +export const defaultHomeBanners: HomeBannerSlide[] = [ + { + eyebrow: '三角洲行动账号专区', + title: '高哈夫币 · 安全交接 · 随租随玩', + badge: 'HOT', + pill: '新人领券最高减 20 元', + tone: 'blue', + image_url: '', + }, + { + eyebrow: '平台担保交易', + title: '交接全程留痕,拒绝私下转账', + badge: 'SAFE', + pill: '租号前先验实名与资料', + tone: 'green', + image_url: '', + }, + { + eyebrow: '高效筛选', + title: '按区服、段位、哈夫币快速找号', + badge: 'FAST', + pill: '支持扫码号与账密号', + tone: 'orange', + image_url: '', + }, +] + +export async function fetchMobileHomeConfig() { + const { data } = await apiClient.get>('/mobile-home-config') + return mergeHomeConfig(data.data) +} + +export function mergeHomeConfig(config?: Partial): MobileHomeConfig { + const announcements = + config?.announcements + ?.map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) || [] + const banners = + config?.banners + ?.map((item) => { + const banner = isBannerLike(item) ? item : ({} as Partial) + return { + eyebrow: banner.eyebrow?.trim() || '', + title: banner.title?.trim() || '', + badge: banner.badge?.trim() || '', + pill: banner.pill?.trim() || '', + tone: normalizeBannerTone(banner.tone), + image_url: banner.image_url?.trim() || '', + } + }) + .filter((item) => item.title || item.image_url) || [] + + return { + announcements: announcements.length ? announcements : defaultHomeAnnouncements, + banners: banners.length ? banners : defaultHomeBanners, + publish_options: mergeListingPublishOptions(config?.publish_options || defaultListingPublishOptions), + } +} + +function isBannerLike(value: unknown): value is Partial { + return typeof value === 'object' && value !== null +} + +function normalizeBannerTone(tone?: string) { + if (tone === 'green' || tone === 'orange') return tone + return 'blue' +} diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue index a69bfde..a1606b9 100644 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ b/frontend/src/views/admin/AdminSystemConfigsView.vue @@ -7,6 +7,13 @@ import { mergeListingPublishOptions, type ListingPublishOptions, } from '@/api/listingOptions' +import { + defaultHomeAnnouncements, + defaultHomeBanners, + mergeHomeConfig, + type HomeBannerSlide, +} from '@/api/homeConfig' +import { uploadAdminFile } from '@/api/files' import { fetchSystemConfigs, updateSystemConfig, type SystemConfig } from '@/api/systemConfigs' const loading = ref(false) @@ -16,10 +23,24 @@ const activeConfig = ref(null) const value = ref('') const description = ref('') const publishOptionsDraft = ref(cloneOptions(defaultListingPublishOptions)) +const homeAnnouncementLines = ref(itemsToLines(defaultHomeAnnouncements)) +const homeBannersDraft = ref(cloneHomeBanners(defaultHomeBanners)) +const uploadingBannerIndex = ref(null) const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null) -const regularConfigs = computed(() => configs.value.filter((item) => item.key !== 'listing.publish_options')) +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 regularConfigs = computed(() => + configs.value.filter( + (item) => + item.key !== 'listing.publish_options' && + item.key !== 'mobile.home_announcements' && + item.key !== 'mobile.home_banners', + ), +) const isPublishOptionsConfig = computed(() => activeConfig.value?.key === 'listing.publish_options') +const isHomeAnnouncementsConfig = computed(() => activeConfig.value?.key === 'mobile.home_announcements') +const isHomeBannersConfig = computed(() => activeConfig.value?.key === 'mobile.home_banners') const publishStats = computed(() => { const options = safeParsePublishOptions(publishConfig.value?.value || '') return { @@ -34,13 +55,31 @@ const publishStats = computed(() => { regionCount: options.region_options.length, } }) +const homeStats = computed(() => { + const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '', false) + const banners = parseHomeBanners(homeBannersConfig.value?.value || '', false) + return { + announcementCount: announcements.length, + bannerCount: banners.length, + } +}) const isStructuredConfig = computed(() => { const trimmed = value.value.trim() - return isPublishOptionsConfig.value || trimmed.startsWith('{') || trimmed.startsWith('[') + return ( + isPublishOptionsConfig.value || + isHomeAnnouncementsConfig.value || + isHomeBannersConfig.value || + trimmed.startsWith('{') || + trimmed.startsWith('[') + ) }) -const dialogWidth = computed(() => (isPublishOptionsConfig.value ? '920px' : '560px')) +const dialogWidth = computed(() => { + if (isPublishOptionsConfig.value || isHomeBannersConfig.value) return '920px' + if (isHomeAnnouncementsConfig.value) return '680px' + return '560px' +}) onMounted(loadConfigs) @@ -60,6 +99,12 @@ function openEdit(row: SystemConfig) { if (row.key === 'listing.publish_options') { publishOptionsDraft.value = parsePublishOptions(row.value) } + if (row.key === 'mobile.home_announcements') { + homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(row.value, true)) + } + if (row.key === 'mobile.home_banners') { + homeBannersDraft.value = parseHomeBanners(row.value, true) + } } async function handleSave() { @@ -69,6 +114,16 @@ async function handleSave() { if (isPublishOptionsConfig.value) { value.value = JSON.stringify(publishOptionsDraft.value, null, 2) } + if (isHomeAnnouncementsConfig.value) { + value.value = JSON.stringify(linesToItems(homeAnnouncementLines.value), null, 2) + } + if (isHomeBannersConfig.value) { + value.value = JSON.stringify( + homeBannersDraft.value.filter((item) => item.title.trim() || item.image_url?.trim()), + null, + 2, + ) + } await updateSystemConfig(activeConfig.value.key, { value: value.value, description: description.value, @@ -102,10 +157,34 @@ function safeParsePublishOptions(raw: string) { } } +function parseHomeAnnouncements(raw: string, showWarning = false) { + try { + const parsed = raw.trim() ? JSON.parse(raw) : defaultHomeAnnouncements + return mergeHomeConfig({ announcements: Array.isArray(parsed) ? parsed : [] }).announcements + } catch { + if (showWarning) ElMessage.warning('首页公告 JSON 解析失败,已使用默认配置') + return [...defaultHomeAnnouncements] + } +} + +function parseHomeBanners(raw: string, showWarning = false) { + try { + const parsed = raw.trim() ? JSON.parse(raw) : defaultHomeBanners + return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners) + } catch { + if (showWarning) ElMessage.warning('首页轮播 JSON 解析失败,已使用默认配置') + return cloneHomeBanners(defaultHomeBanners) + } +} + function cloneOptions(options: ListingPublishOptions) { return JSON.parse(JSON.stringify(options)) as ListingPublishOptions } +function cloneHomeBanners(banners: HomeBannerSlide[]) { + return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[] +} + function linesToItems(value: string) { return value .split('\n') @@ -169,6 +248,57 @@ function resetPublishOptions() { publishOptionsDraft.value = cloneOptions(defaultListingPublishOptions) } +function addHomeBanner() { + homeBannersDraft.value.push({ + eyebrow: '首页推荐', + title: '', + badge: 'NEW', + pill: '', + tone: 'blue', + image_url: '', + }) +} + +function removeHomeBanner(index: number) { + homeBannersDraft.value.splice(index, 1) +} + +function resetHomeAnnouncements() { + homeAnnouncementLines.value = itemsToLines(defaultHomeAnnouncements) +} + +function resetHomeBanners() { + homeBannersDraft.value = cloneHomeBanners(defaultHomeBanners) +} + +async function handleHomeBannerUpload(event: Event, index: number) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + uploadingBannerIndex.value = index + try { + const uploaded = await uploadAdminFile(file, 'home-banner') + const banner = homeBannersDraft.value[index] + if (banner) { + banner.image_url = uploaded.url + if (!banner.title) { + banner.title = banner.eyebrow || '首页轮播图' + } + } + ElMessage.success('图片已上传') + } catch (error) { + ElMessage.error(readError(error, '图片上传失败')) + } finally { + uploadingBannerIndex.value = null + } +} + +function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) { + if (!row) return fallback + return row.updated_at || fallback +} + function formatConfigValue(row: SystemConfig) { const trimmed = row.value.trim() if (trimmed.startsWith('{') || trimmed.startsWith('[')) { @@ -227,6 +357,42 @@ function readError(error: unknown, fallback: string) { +
+
+
+

Home Content

+

移动端首页运营配置

+ 管理首页公告滚动内容和顶部轮播图,保存后移动端会从接口读取最新配置。 +
+
+ 编辑公告 + 编辑轮播图 +
+
+
+
+ {{ homeStats.announcementCount }} + 公告条数 +
+
+ {{ homeStats.bannerCount }} + 轮播图 +
+
+ 接口 + /api/mobile-home-config +
+
+ 公告 + {{ formatHomeConfigStatus(homeAnnouncementsConfig, '未初始化') }} +
+
+ 轮播 + {{ formatHomeConfigStatus(homeBannersConfig, '未初始化') }} +
+
+
+ @@ -394,6 +560,74 @@ function readError(error: unknown, fallback: string) { +
+
+ 首页公告 + 恢复默认公告 +
+ + + +
+ +
+
+ 首页轮播图 +
+ 恢复默认轮播 + 添加轮播 +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ @@ -424,6 +658,12 @@ function readError(error: unknown, fallback: string) { gap: 16px; } +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + .publish-config-main h2 { margin: 4px 0 6px; color: #111827; @@ -441,6 +681,10 @@ function readError(error: unknown, fallback: string) { gap: 10px; } +.home-stat-grid .publish-stat strong { + font-size: 18px; +} + .publish-stat { display: grid; gap: 4px; @@ -466,6 +710,60 @@ function readError(error: unknown, fallback: string) { gap: 16px; } +.home-config-editor { + display: grid; + gap: 14px; +} + +.banner-image-editor { + display: grid; + gap: 8px; +} + +.banner-image-tools { + display: flex; + align-items: center; + gap: 8px; +} + +.banner-image-tools img, +.banner-image-tools span { + width: 68px; + height: 38px; + border-radius: 6px; + background: #f3f4f6; + object-fit: cover; +} + +.banner-image-tools span { + display: grid; + place-items: center; + color: #9ca3af; + font-size: 12px; +} + +.upload-trigger { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 10px; + border: 1px solid #dcdfe6; + border-radius: 4px; + background: #fff; + color: #606266; + cursor: pointer; + font-size: 12px; +} + +.upload-trigger input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + .editor-toolbar, .editor-block-title { display: flex; diff --git a/frontend/src/views/mobile/MobileHomeView.css b/frontend/src/views/mobile/MobileHomeView.css index 83cc0dc..2e28dde 100644 --- a/frontend/src/views/mobile/MobileHomeView.css +++ b/frontend/src/views/mobile/MobileHomeView.css @@ -5,12 +5,10 @@ padding-bottom: 58px; } -/* ========== Hero 区域:纯色蓝色头部 ========== */ +/* ========== 顶部信息区 ========== */ .mobile-hero { - overflow: hidden; - border-radius: 0 0 24px 24px; - background: #1479ff; - padding: 14px 14px 18px; + padding: 14px 14px 8px; + background: #f5f7fa; } .mobile-topbar, @@ -20,22 +18,25 @@ } .mobile-topbar { + gap: 10px; justify-content: space-between; - color: #ffffff; + color: #17233d; } .mobile-brand { + flex: 0 0 auto; gap: 10px; } .mobile-logo { display: grid; - width: 38px; - height: 38px; + width: 36px; + height: 36px; place-items: center; border-radius: 12px; background: #ffffff; - color: #1479ff; + color: #ff6a00; + box-shadow: 0 6px 16px rgba(23, 35, 61, 0.08); font-weight: 900; } @@ -46,34 +47,46 @@ .mobile-brand small { margin-top: 2px; - color: rgba(255, 255, 255, 0.78); - font-size: 12px; + color: #7b8798; + font-size: 11px; +} + +.mobile-brand strong { + font-size: 15px; + white-space: nowrap; } .mobile-service { + flex: 0 0 auto; border: 0; border-radius: 999px; - background: rgba(255, 255, 255, 0.18); - color: #ffffff; - padding: 7px 12px; + background: #ffffff; + color: #1477ff; + padding: 7px 10px; + box-shadow: 0 6px 16px rgba(23, 35, 61, 0.08); + font-size: 13px; + font-weight: 800; } /* ========== van-search 覆盖样式 ========== */ .home-search { - margin-top: 12px; + flex: 1 1 auto; + min-width: 0; padding: 0; background: transparent; } .home-search :deep(.van-search__content) { border-radius: 999px; + border: 1px solid #eef1f5; background: #ffffff; - padding: 8px 14px; + padding: 6px 10px; + box-shadow: 0 8px 20px rgba(23, 35, 61, 0.06); } .home-search :deep(.van-search__content .van-field__control) { color: #17233d; - font-size: 14px; + font-size: 12px; } /* ========== 防骗提示卡片 ========== */ @@ -83,13 +96,28 @@ gap: 8px; margin-top: 12px; border-radius: 12px; - background: #ffffff; + border: 1px solid #ffe6bd; + background: #fffaf0; padding: 10px 14px; font-size: 12px; line-height: 1.45; color: #5c3b00; } +.announcement-swipe { + flex: 1; + height: 18px; + min-width: 0; +} + +.announcement-swipe span { + display: block; + overflow: hidden; + color: #5c3b00; + text-overflow: ellipsis; + white-space: nowrap; +} + .fraud-dot { display: inline-block; width: 6px; @@ -101,7 +129,7 @@ /* ========== Content 区 ========== */ .mobile-content { - padding: 14px; + padding: 8px 14px 14px; } /* ========== Banner 轮播 ========== */ @@ -123,10 +151,12 @@ .banner-swipe :deep(.van-swipe__indicator--active) { width: 16px; border-radius: 999px; - background: #1479ff; + background: #ff6a00; } .mobile-banner { + position: relative; + overflow: hidden; display: flex; justify-content: space-between; gap: 14px; @@ -137,6 +167,34 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); } +.mobile-banner.has-image { + min-height: 132px; + align-items: flex-end; + background: #17233d; + color: #ffffff; +} + +.banner-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.mobile-banner.has-image::after { + position: absolute; + inset: 0; + content: ""; + background: linear-gradient(90deg, rgba(10, 18, 32, 0.64), rgba(10, 18, 32, 0.12)); +} + +.mobile-banner.has-image > div:not(.banner-badge) { + position: relative; + z-index: 1; + max-width: 78%; +} + .mobile-banner.tone-green { background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); } @@ -151,11 +209,17 @@ } .mobile-banner p { - color: #1479ff; + color: #1477ff; font-size: 13px; font-weight: 800; } +.mobile-banner.has-image p, +.mobile-banner.has-image h1 { + color: #ffffff; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.32); +} + .mobile-banner h1 { margin-top: 6px; font-size: 22px; @@ -174,6 +238,8 @@ } .banner-badge { + position: relative; + z-index: 1; align-self: flex-start; border-radius: 12px; background: #ff6a00; diff --git a/frontend/src/views/mobile/MobileHomeView.vue b/frontend/src/views/mobile/MobileHomeView.vue index a2a02e2..c9c9e36 100644 --- a/frontend/src/views/mobile/MobileHomeView.vue +++ b/frontend/src/views/mobile/MobileHomeView.vue @@ -5,10 +5,15 @@ import { showToast } from "vant"; import { defaultListingPublishOptions, - fetchListingPublishOptions, type ListingPublishOptions, } from "@/api/listingOptions"; import { fetchListings, type Listing } from "@/api/listings"; +import { + defaultHomeAnnouncements, + defaultHomeBanners, + fetchMobileHomeConfig, + type HomeBannerSlide, +} from "@/api/homeConfig"; import MobileHomeFilterSheet, { type FilterSection, } from "./MobileHomeFilterSheet.vue"; @@ -45,6 +50,8 @@ const selectedFilters = ref>({}); const rangeFilters = ref>({}); const refreshing = ref(false); const searchValue = ref(""); +const announcements = ref(defaultHomeAnnouncements); +const bannerSlides = ref(defaultHomeBanners); const sortOptions = [ { key: "comprehensive", label: "综合排序" }, @@ -70,30 +77,6 @@ const rangePresets: Record buildFallbackListings(publishOptions.value)); const sourceListings = computed(() => @@ -178,7 +161,7 @@ function isNavActive(path: string) { onMounted(() => { loadListings(); - loadPublishOptions(); + loadHomeConfig(); }); async function loadListings() { @@ -193,10 +176,15 @@ async function loadListings() { } } -async function loadPublishOptions() { +async function loadHomeConfig() { try { - publishOptions.value = await fetchListingPublishOptions(); + const config = await fetchMobileHomeConfig(); + announcements.value = config.announcements; + bannerSlides.value = config.banners; + publishOptions.value = config.publish_options; } catch { + announcements.value = defaultHomeAnnouncements; + bannerSlides.value = defaultHomeBanners; publishOptions.value = defaultListingPublishOptions; } } @@ -204,12 +192,14 @@ async function loadPublishOptions() { async function onRefresh() { refreshing.value = true; try { - const [nextListings, nextOptions] = await Promise.all([ + const [nextListings, nextHomeConfig] = await Promise.all([ fetchListings(), - fetchListingPublishOptions(), + fetchMobileHomeConfig(), ]); listings.value = nextListings; - publishOptions.value = nextOptions; + announcements.value = nextHomeConfig.announcements; + bannerSlides.value = nextHomeConfig.banners; + publishOptions.value = nextHomeConfig.publish_options; showToast({ message: "刷新成功", icon: "passed" }); } catch { // 静默处理 @@ -335,7 +325,7 @@ function uniqueOptions(values: string[]) {