diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx
index efdb296..9a54231 100644
--- a/distribution-admin/src/pages/SiteSettings.tsx
+++ b/distribution-admin/src/pages/SiteSettings.tsx
@@ -1149,7 +1149,7 @@ export default function SiteSettings() {
>
- {announcements[0]?.title}
+ {announcements[0]?.title || announcements[0]?.content}
)}
diff --git a/frontend/src/features/listings/api/homeConfig.ts b/frontend/src/features/listings/api/homeConfig.ts
index a08c724..6050163 100644
--- a/frontend/src/features/listings/api/homeConfig.ts
+++ b/frontend/src/features/listings/api/homeConfig.ts
@@ -1,5 +1,6 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
+import { normalizeAnnouncements } from '@/shared/utils/announcements'
import { mergeListingPublishOptions, type ListingPublishOptions } from './listingOptions'
export interface HomeBannerSlide {
@@ -86,10 +87,8 @@ export async function fetchMobileHomeConfig() {
}
export function mergeHomeConfig(config?: Partial): MobileHomeConfig {
- const announcements =
- config?.announcements
- ?.map(item => (typeof item === 'string' ? item.trim() : ''))
- .filter(Boolean) || []
+ // 公告可能是纯文本,也可能是 { title, content } 对象(分销站装修口径),统一归一化。
+ const announcements = normalizeAnnouncements(config?.announcements)
const banners =
config?.banners
?.map(item => {
diff --git a/frontend/src/features/listings/components/HomeAnnouncement.vue b/frontend/src/features/listings/components/HomeAnnouncement.vue
index d03d27d..0d04dd1 100644
--- a/frontend/src/features/listings/components/HomeAnnouncement.vue
+++ b/frontend/src/features/listings/components/HomeAnnouncement.vue
@@ -1,11 +1,17 @@
@@ -18,7 +24,7 @@ defineProps()
:autoplay="true"
:interval="3200"
>
-
+
{{ item }}
diff --git a/frontend/src/shared/utils/__tests__/announcements.spec.ts b/frontend/src/shared/utils/__tests__/announcements.spec.ts
new file mode 100644
index 0000000..061d18e
--- /dev/null
+++ b/frontend/src/shared/utils/__tests__/announcements.spec.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+
+import { normalizeAnnouncements, normalizeAnnouncementText } from '../announcements'
+
+describe('normalizeAnnouncementText', () => {
+ it('保留纯文本公告并去掉首尾空白', () => {
+ expect(normalizeAnnouncementText(' 平台担保交易,拒绝私下转账 ')).toBe(
+ '平台担保交易,拒绝私下转账'
+ )
+ })
+
+ it('对象公告取标题', () => {
+ expect(
+ normalizeAnnouncementText({ title: '天域商行专注AWM子弹回收、高比例!', content: '详情' })
+ ).toBe('天域商行专注AWM子弹回收、高比例!')
+ })
+
+ it('对象公告标题为空时回退到内容', () => {
+ expect(normalizeAnnouncementText({ title: ' ', content: ' 详情内容 ' })).toBe('详情内容')
+ })
+
+ it('兼容被字符串化的对象(历史脏数据)', () => {
+ expect(
+ normalizeAnnouncementText('{"title":"天域商行专注AWM子弹回收、高比例! ","content":""}')
+ ).toBe('天域商行专注AWM子弹回收、高比例!')
+ })
+
+ it('普通花括号文案不会被当成 JSON 配置吞掉', () => {
+ expect(normalizeAnnouncementText('{今日特价} 全场九折')).toBe('{今日特价} 全场九折')
+ expect(normalizeAnnouncementText('{不完整对象')).toBe('{不完整对象')
+ })
+
+ it('空值与非法类型返回空串', () => {
+ expect(normalizeAnnouncementText('')).toBe('')
+ expect(normalizeAnnouncementText(' ')).toBe('')
+ expect(normalizeAnnouncementText(null)).toBe('')
+ expect(normalizeAnnouncementText(undefined)).toBe('')
+ expect(normalizeAnnouncementText(123)).toBe('')
+ expect(normalizeAnnouncementText({ foo: 'bar' })).toBe('')
+ })
+})
+
+describe('normalizeAnnouncements', () => {
+ it('归一化站点公告对象数组(真实线上数据)', () => {
+ const raw = JSON.parse(
+ '[{"title":"平台全新上线精选三角洲账号,安全畅玩!","content":""},{"title":"天域商行专注AWM子弹回收、高比例!不排队!秒结算!","content":""}]'
+ )
+ expect(normalizeAnnouncements(raw)).toEqual([
+ '平台全新上线精选三角洲账号,安全畅玩!',
+ '天域商行专注AWM子弹回收、高比例!不排队!秒结算!',
+ ])
+ })
+
+ it('支持字符串与对象混排并去重', () => {
+ expect(
+ normalizeAnnouncements([
+ 'A',
+ { title: 'B' },
+ 'A',
+ { title: 'B', content: 'B' },
+ ' ',
+ { title: '' },
+ ])
+ ).toEqual(['A', 'B'])
+ })
+
+ it('非数组输入返回空列表', () => {
+ expect(normalizeAnnouncements(undefined)).toEqual([])
+ expect(normalizeAnnouncements(null)).toEqual([])
+ expect(normalizeAnnouncements('A')).toEqual([])
+ expect(normalizeAnnouncements({ title: 'A' })).toEqual([])
+ })
+})
diff --git a/frontend/src/shared/utils/announcements.ts b/frontend/src/shared/utils/announcements.ts
new file mode 100644
index 0000000..eeaf808
--- /dev/null
+++ b/frontend/src/shared/utils/announcements.ts
@@ -0,0 +1,62 @@
+/**
+ * 公告条目的统一归一化。
+ *
+ * 站点公告在系统里有两种历史形态:
+ * - 主站首页配置(systemconfigs.mobile-home-config):纯文本字符串数组
+ * - 分销站品牌装修(distribution_sites.announcements_json):{ title, content } 对象数组
+ *
+ * 跑马灯只展示标题。若把对象直接交给模板渲染,Vue 会对对象做 JSON.stringify,
+ * 前台就会出现 {"title":"…","content":""} 这样的原文(跑马灯样式还会把换行压成一行)。
+ * 因此所有公告入口都先经过这里收敛为可展示文本。
+ */
+export interface AnnouncementLike {
+ title?: string
+ content?: string
+}
+
+/** 把单个公告条目归一化为展示文本,兼容纯文本、对象与「被字符串化的对象」。 */
+export function normalizeAnnouncementText(item: unknown): string {
+ if (typeof item === 'string') {
+ const text = item.trim()
+ if (!text) return ''
+ const parsed = parseStringifiedObject(text)
+ return parsed ? normalizeAnnouncementText(parsed) : text
+ }
+ if (item && typeof item === 'object') {
+ const record = item as Record
+ const title = typeof record.title === 'string' ? record.title.trim() : ''
+ if (title) return title
+ const content = typeof record.content === 'string' ? record.content.trim() : ''
+ return content
+ }
+ return ''
+}
+
+/** 把任意来源的公告列表归一化为去重后的展示文本列表。 */
+export function normalizeAnnouncements(items: unknown): string[] {
+ if (!Array.isArray(items)) return []
+ const seen = new Set()
+ const result: string[] = []
+ for (const item of items) {
+ const text = normalizeAnnouncementText(item)
+ if (!text || seen.has(text)) continue
+ seen.add(text)
+ result.push(text)
+ }
+ return result
+}
+
+/** 只解析「看起来是 JSON 对象」的字符串,避免把普通文案误当 JSON 处理。 */
+function parseStringifiedObject(text: string): Record | undefined {
+ if (!text.startsWith('{') || !text.endsWith('}')) return undefined
+ try {
+ const parsed: unknown = JSON.parse(text)
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
+ const record = parsed as Record
+ // 仅接受公告结构,避免把用户手写的 {xxx} 文案当成配置吞掉。
+ if (typeof record.title === 'string' || typeof record.content === 'string') return record
+ return undefined
+ } catch {
+ return undefined
+ }
+}
diff --git a/frontend/src/stores/distributionSite.ts b/frontend/src/stores/distributionSite.ts
index 0e76da4..8d506bb 100644
--- a/frontend/src/stores/distributionSite.ts
+++ b/frontend/src/stores/distributionSite.ts
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
+import { normalizeAnnouncements } from '@/shared/utils/announcements'
export interface DistributionBanner {
image_url: string
@@ -160,7 +161,9 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
if (info.announcements_json) {
try {
- this.announcements = JSON.parse(info.announcements_json)
+ // 站点公告存的是 { title, content } 对象数组,跑马灯只认文本,必须先归一化,
+ // 否则 Vue 会把对象 stringify 成 {"title":"…","content":""} 原文展示。
+ this.announcements = normalizeAnnouncements(JSON.parse(info.announcements_json))
} catch {
this.announcements = []
}