fix(distribution): 修复分站跑马灯公告把配置 JSON 原文展示出来

分销站公告存在 distribution_sites.announcements_json 的是 { title, content } 对象数组
(分销后台品牌装修写入),而前台跑马灯组件按字符串数组渲染。Vue 对对象会做
JSON.stringify,配合 no-wrap 样式,前台就显示成
{"title":"天域商行专注AWM子弹回收、高比例!不排队!秒结算!","content":""}。

- 新增 shared/utils/announcements.ts:把公告条目归一化为展示文本,兼容纯文本、
  { title, content } 对象,以及被字符串化的对象(历史脏数据)与标题为空回退内容
- distributionSite store 解析 announcements_json 时归一化,跑马灯组件内部再兜底一次
- 首页配置 mergeHomeConfig 同样接受对象形态,主站/移动端公告入口口径统一
- 分销后台装修预览的跑马灯示例按同一口径取值(只有内容时不再空白)
- 补 9 条单测覆盖对象、字符串化对象、普通花括号文案、混排去重与空输入
This commit is contained in:
yml2213
2026-10-01 09:04:28 +08:00
parent 766c923a6a
commit 53b40f8fad
6 changed files with 152 additions and 9 deletions
@@ -1149,7 +1149,7 @@ export default function SiteSettings() {
>
<NotificationOutlined />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{announcements[0]?.title}
{announcements[0]?.title || announcements[0]?.content}
</span>
</div>
)}
@@ -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>): 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 => {
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { normalizeAnnouncements, type AnnouncementLike } from '@/shared/utils/announcements'
interface Props {
announcements: string[]
// 兼容历史形态:纯文本、{ title, content } 对象,或其中任意来源的混合数组。
announcements: Array<string | AnnouncementLike>
}
defineProps<Props>()
const props = defineProps<Props>()
// 组件自身再兜底归一化一次:即使调用方直接传了站点公告对象,也不会渲染出 JSON 原文。
const items = computed(() => normalizeAnnouncements(props.announcements))
</script>
<template>
@@ -18,7 +24,7 @@ defineProps<Props>()
:autoplay="true"
:interval="3200"
>
<el-carousel-item v-for="item in announcements" :key="item">
<el-carousel-item v-for="item in items" :key="item">
<span>{{ item }}</span>
</el-carousel-item>
</el-carousel>
@@ -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([])
})
})
@@ -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<string, unknown>
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<string>()
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<string, unknown> | 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<string, unknown>
// 仅接受公告结构,避免把用户手写的 {xxx} 文案当成配置吞掉。
if (typeof record.title === 'string' || typeof record.content === 'string') return record
return undefined
} catch {
return undefined
}
}
+4 -1
View File
@@ -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 = []
}