Files
frontend-miniapp/src/data/adapters/backendExplainDataAdapter.ts
lyf 2edb6bd9f2
Some checks failed
CI / verify (push) Has been cancelled
适配H5讲解统一详情接口
2026-08-05 14:52:36 +08:00

696 lines
25 KiB
TypeScript

import type {
AudioPlayTargetType,
ExplainBusinessUnit,
ExplainGuideStop,
ExplainTrack,
MediaAsset,
MuseumExhibit,
MuseumHall
} from '@/domain/museum'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
import {
EXHIBIT_PLACEHOLDER_IMAGE,
HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
import {
normalizeGuideAudioLanguage,
type GuideAudioLanguage
} from '@/data/adapters/guideStopInfoAdapter'
export interface BackendGuideContent {
id?: string | number | null
title?: string | null
targetType?: string | null
targetId?: string | number | null
standardText?: string | null
extendedText?: string | null
standardAudioUrl?: string | null
extendedAudioUrl?: string | null
audioUrl?: string | null
standardAudioDuration?: number | null
extendedAudioDuration?: number | null
audioDuration?: number | null
}
export interface BackendExhibit {
id?: string | number | null
hallId?: string | number | null
hallName?: string | null
floorId?: string | number | null
floorLabel?: string | null
poiId?: string | number | null
exhibitCode?: string | null
name?: string | null
scientificName?: string | null
category?: string | null
era?: string | null
origin?: string | null
dimensions?: string | null
description?: string | null
narrationText?: string | null
coverImageUrl?: string | null
galleryUrls?: string | string[] | null
audioUrl?: string | null
audioDuration?: number | null
playTargetType?: string | null
playTargetId?: string | number | null
hasAudio?: boolean | null
supportedLanguages?: string[] | null
audioStatus?: string | null
guideContents?: BackendGuideContent[] | null
}
export interface BackendHall {
id?: string | number | null
name?: string | null
poiId?: string | number | null
hallCode?: string | null
nameEn?: string | null
subtitle?: string | null
description?: string | null
coverImageUrl?: string | null
floorId?: string | number | null
floorCode?: string | null
exhibitCount?: number | null
outlineCount?: number | null
stopCount?: number | null
linkedExhibitCount?: number | null
audioReadyStopCount?: number | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
}
export interface BackendGuideStop {
id?: string | number | null
name?: string | null
floorId?: string | number | null
mapX?: number | string | null
mapY?: number | string | null
targetType?: string | null
targetId?: string | number | null
coverImageUrl?: string | null
description?: string | null
hasAudio?: boolean | null
audioUrl?: string | null
poiId?: string | number | null
outlineId?: string | number | null
outlineName?: string | null
hallId?: string | number | null
hallName?: string | null
sort?: number | null
}
export interface BackendCatalogHallItem extends BackendHall {
mapId?: string | number | null
areaSqm?: string | number | null
sortOrder?: number | null
}
export interface BackendCatalogOutlineItem {
id?: string | number | null
parentId?: string | number | null
hallId?: string | number | null
name?: string | null
code?: string | null
description?: string | null
sort?: number | null
level?: number | null
children?: BackendCatalogOutlineItem[] | null
stopCount?: number | null
linkedExhibitCount?: number | null
audioReadyStopCount?: number | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
}
export interface BackendCatalogLinkedExhibitItem {
id?: string | number | null
name?: string | null
nameEn?: string | null
code?: string | null
exhibitCode?: string | null
coverImageUrl?: string | null
galleryUrls?: string | string[] | null
sortOrder?: number | null
}
export interface BackendCatalogStopItem {
stopId?: string | number | null
id?: string | number | null
outlineId?: string | number | null
poiId?: string | number | null
floorId?: string | number | null
mapX?: number | string | null
mapY?: number | string | null
name?: string | null
guideLevel?: string | null
description?: string | null
sort?: number | null
coverImageUrl?: string | null
imageStatus?: string | null
linkedExhibitCount?: number | null
isSharedStop?: boolean | null
linkedExhibits?: BackendCatalogLinkedExhibitItem[] | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
audioOptions?: Array<{
version?: string | null
languageCode?: string | null
languageName?: string | null
gender?: string | null
displayName?: string | null
isDefault?: boolean | null
}> | null
}
export interface BackendExplainAdapterResult {
exhibit: MuseumExhibit
track: ExplainTrack
media: MediaAsset | null
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
const firstText = (...values: Array<string | number | null | undefined>) => (
values
.map((value) => (value === null || typeof value === 'undefined' ? '' : String(value).trim()))
.find(Boolean) || ''
)
const parseGalleryUrls = (value: BackendExhibit['galleryUrls'] | string[]) => {
if (!value) return []
if (Array.isArray(value)) return value.map(String).filter(Boolean)
const trimmed = value.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter(Boolean)
}
} catch {
// 兼容后端历史逗号拼接字段。
}
return trimmed.split(',').map((entry) => entry.trim()).filter(Boolean)
}
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | undefined => {
const normalized = targetType?.toUpperCase()
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined
}
const normalizeNumber = (value: number | string | null | undefined) => {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : undefined
}
return undefined
}
const isAudioReady = (value?: string | null) => (
!value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase())
)
const resolveImage = (exhibit: BackendExhibit) => normalizeSameOriginPublicUrl(
firstText(exhibit.coverImageUrl, parseGalleryUrls(exhibit.galleryUrls)[0])
)
const resolveGuideAudioUrl = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => normalizeSameOriginPublicUrl(
firstText(guide?.standardAudioUrl, guide?.audioUrl, guide?.extendedAudioUrl, exhibit?.audioUrl)
)
const resolveGuideAudioDuration = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => {
const duration = guide?.standardAudioDuration
|| guide?.audioDuration
|| guide?.extendedAudioDuration
|| exhibit?.audioDuration
return typeof duration === 'number' && Number.isFinite(duration) && duration > 0 ? duration : undefined
}
const resolveGuideText = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => (
firstText(guide?.standardText, guide?.extendedText, exhibit?.narrationText, exhibit?.description)
)
const selectPrimaryGuideContent = (exhibit: BackendExhibit) => {
const guides = exhibit.guideContents || []
return guides.find((guide) => Boolean(resolveGuideAudioUrl(guide, exhibit)))
|| guides.find((guide) => Boolean(resolveGuideText(guide, exhibit)))
|| guides[0]
|| null
}
const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
firstText(exhibit.category),
firstText(exhibit.scientificName),
firstText(exhibit.exhibitCode),
firstText(exhibit.era),
firstText(exhibit.origin)
].filter(Boolean)))
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.from(new Set((languages || [])
.map(String)
.filter((language) => ['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk']
.includes(language.trim().toLowerCase()))
.map(normalizeGuideAudioLanguage)))
)
const normalizeCatalogAudioStatus = (status?: string | null) => (
firstText(status) || 'MISSING'
)
export const toCatalogHall = (
source: BackendCatalogHallItem,
fallback?: MuseumHall | null
): MuseumHall => {
const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name)
const name = firstText(source.name, fallback?.name, source.hallCode, '展厅')
const area = firstText(source.areaSqm)
return {
id,
name,
floorId: stringifyId(source.floorId) || fallback?.floorId,
floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: normalizeNumber(source.exhibitCount) ?? fallback?.exhibitCount ?? 0,
outlineCount: normalizeNumber(source.outlineCount),
stopCount: normalizeNumber(source.stopCount),
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
area: area ? `${area}` : fallback?.area,
poiId: stringifyId(source.poiId) || fallback?.poiId,
location: fallback?.location
}
}
export const toCatalogLinkedExhibit = (
source: BackendCatalogLinkedExhibitItem,
hall?: MuseumHall | null
): MuseumExhibit | null => {
const id = stringifyId(source.id)
if (!id) return null
return {
id,
name: firstText(source.name, source.exhibitCode, source.code, `展品${id}`),
hallId: hall?.id,
hallName: hall?.name,
floorId: hall?.floorId,
floorLabel: hall?.floorLabel,
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
size: firstText(source.exhibitCode, source.code) || undefined
}
}
const toCatalogLinkedExhibitSummary = (source: BackendCatalogLinkedExhibitItem) => {
const id = stringifyId(source.id)
if (!id) return null
return {
id,
name: firstText(source.name, source.exhibitCode, source.code, `展品 ${id}`),
nameEn: firstText(source.nameEn) || undefined,
code: firstText(source.code, source.exhibitCode) || undefined,
exhibitCode: firstText(source.exhibitCode, source.code) || undefined,
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
galleryUrls: parseGalleryUrls(source.galleryUrls),
sortOrder: normalizeNumber(source.sortOrder)
}
}
export const toCatalogGuideStop = (
source: BackendCatalogStopItem,
hall?: MuseumHall | null,
outline?: Pick<ExplainBusinessUnit, 'id' | 'name'> | null
): ExplainGuideStop | null => {
const id = stringifyId(source.stopId) || stringifyId(source.id)
if (!id) return null
const imageStatus = firstText(source.imageStatus) || 'MISSING'
const canUseStopImage = imageStatus === 'READY'
return {
id,
name: firstText(source.name, `讲解点${id}`),
hallId: hall?.id,
hallName: hall?.name,
floorId: stringifyId(source.floorId) || hall?.floorId,
coverImageUrl: canUseStopImage
? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
: undefined,
description: firstText(source.description) || undefined,
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
poiId: stringifyId(source.poiId) || undefined,
mapX: normalizeNumber(source.mapX),
mapY: normalizeNumber(source.mapY),
outlineId: stringifyId(source.outlineId) || outline?.id,
outlineName: outline?.name,
sort: typeof source.sort === 'number' ? source.sort : undefined,
guideLevel: firstText(source.guideLevel) || undefined,
imageStatus,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
isSharedStop: source.isSharedStop === true,
linkedExhibits: (source.linkedExhibits || [])
.map(toCatalogLinkedExhibitSummary)
.filter(Boolean) as NonNullable<ExplainGuideStop['linkedExhibits']>,
stopId: id,
// Catalog audio metadata is intentionally display-only: playback always
// comes from the unified stop detail and therefore has no URL here.
audioOptions: (source.audioOptions || [])
.map((option) => {
const gender = option.gender?.trim().toLowerCase()
if (gender !== 'male' && gender !== 'female') return null
const languageCode = normalizeGuideAudioLanguage(option.languageCode)
const version = option.version?.trim().toLowerCase() === 'extended' ? 'extended' : 'standard'
return {
channelCode: `${version}.${languageCode}.${gender}`,
version,
languageCode,
languageName: firstText(option.languageName) || undefined,
gender,
displayName: firstText(option.displayName) || undefined,
playUrl: '',
isDefault: option.isDefault === true
}
})
.filter(Boolean) as NonNullable<ExplainGuideStop['audioOptions']>
}
}
export const toCatalogMuseumExhibitFromStop = (
stop: ExplainGuideStop,
hall?: MuseumHall | null
): MuseumExhibit => {
const linkedPrimary = stop.linkedExhibits?.[0]
const audioReady = stop.audioStatus === 'READY'
return {
id: stop.id,
name: stop.name,
hallId: stop.hallId || hall?.id,
hallName: stop.hallName || hall?.name,
floorId: stop.floorId || hall?.floorId,
floorLabel: hall?.floorLabel,
image: stop.coverImageUrl || undefined,
description: stop.description || linkedPrimary?.name || '该讲解点暂无简介。',
tags: [stop.outlineName, linkedPrimary?.exhibitCode].filter(Boolean) as string[],
poiId: stop.poiId,
sourcePoiId: stop.poiId,
mapX: stop.mapX,
mapY: stop.mapY,
guideTitle: stop.name,
guideText: stop.description,
audioAvailable: audioReady,
audioStatus: stop.audioStatus,
supportedLanguages: stop.supportedLanguages,
// List summaries do not carry authoritative text availability. It is read
// from the unified detail together with the full text variants.
audioHasText: false,
imageStatus: stop.imageStatus,
linkedExhibitCount: stop.linkedExhibitCount,
isSharedStop: stop.isSharedStop,
linkedExhibits: stop.linkedExhibits,
stopInfoAvailable: true,
resolvedStopId: stop.id
}
}
const outlineKey = (outlineId?: string | null) => outlineId || '__unassigned__'
export const flattenCatalogOutlines = (
outlines: BackendCatalogOutlineItem[]
): BackendCatalogOutlineItem[] => {
const flattened: BackendCatalogOutlineItem[] = []
const visit = (items: BackendCatalogOutlineItem[]) => {
items
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.forEach((item) => {
flattened.push(item)
if (Array.isArray(item.children) && item.children.length) {
visit(item.children)
}
})
}
visit(outlines)
return flattened
}
export const toCatalogBusinessUnits = (
hallId: string,
outlines: BackendCatalogOutlineItem[],
stops: ExplainGuideStop[]
): ExplainBusinessUnit[] => {
const flatOutlines = flattenCatalogOutlines(outlines)
const stopMap = new Map<string, ExplainGuideStop[]>()
stops.forEach((stop) => {
const key = outlineKey(stop.outlineId)
stopMap.set(key, [...(stopMap.get(key) || []), stop])
})
const outlineChildren = new Set(
flatOutlines
.map((item) => stringifyId(item.parentId))
.filter((parentId) => parentId && parentId !== hallId)
)
const units = flatOutlines
.map<ExplainBusinessUnit | null>((outline) => {
const id = stringifyId(outline.id)
if (!id) return null
const directStops = (stopMap.get(id) || [])
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const hasChildOutlines = outlineChildren.has(id)
const rawStopCount = normalizeNumber(outline.stopCount) ?? 0
if (!directStops.length && hasChildOutlines) return null
return {
id,
name: firstText(outline.name, outline.code, `业务单元${id}`),
hallId: stringifyId(outline.hallId) || hallId,
parentId: stringifyId(outline.parentId) || undefined,
code: firstText(outline.code) || undefined,
description: firstText(outline.description) || undefined,
sort: typeof outline.sort === 'number' ? outline.sort : undefined,
level: typeof outline.level === 'number' ? outline.level : undefined,
guideStopCount: directStops.length || rawStopCount,
linkedExhibitCount: normalizeNumber(outline.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(outline.audioReadyStopCount),
hasAudio: outline.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(outline.audioStatus),
supportedLanguages: normalizeSupportedLanguages(outline.supportedLanguages),
stops: directStops
}
})
.filter(Boolean) as ExplainBusinessUnit[]
if (units.length) return units
return groupGuideStopsByOutline(hallId, stops)
}
export const toBackendMuseumExhibit = (
source: BackendExhibit,
hall?: MuseumHall | null,
options: { includeDetail?: boolean } = {}
): MuseumExhibit => {
const id = stringifyId(source.id)
const guide = options.includeDetail ? selectPrimaryGuideContent(source) : null
const guideText = options.includeDetail ? resolveGuideText(guide, source) : firstText(source.narrationText, source.description)
const audioUrl = options.includeDetail ? resolveGuideAudioUrl(guide, source) : normalizeSameOriginPublicUrl(firstText(source.audioUrl))
const targetType = normalizeAudioTargetType(guide?.targetType || source.playTargetType)
const targetId = stringifyId(guide?.targetId || source.playTargetId)
const supportedLanguages = normalizeSupportedLanguages(source.supportedLanguages)
const supportedLanguage = supportedLanguages[0]
const metadataSuggestsAudio = source.hasAudio === true && isAudioReady(source.audioStatus)
const audioAvailable = Boolean(audioUrl)
const sourcePoiId = stringifyId(source.poiId)
const location = hall?.location
return {
id,
name: firstText(source.name, source.scientificName, source.exhibitCode, `展品${id}`),
hallId: stringifyId(source.hallId) || hall?.id,
hallName: firstText(source.hallName, hall?.name),
floorId: stringifyId(source.floorId) || hall?.floorId,
floorLabel: firstText(source.floorLabel, hall?.floorLabel),
image: resolveImage(source) || EXHIBIT_PLACEHOLDER_IMAGE,
description: firstText(source.description, source.narrationText, source.scientificName, '该展项暂无讲解文稿。'),
poiId: sourcePoiId || location?.poiId || undefined,
sourcePoiId: sourcePoiId || location?.sourcePoiId,
location,
year: firstText(source.era) || undefined,
material: firstText(source.origin) || undefined,
size: firstText(source.exhibitCode, source.dimensions) || undefined,
tags: buildTags(source),
guideContentId: stringifyId(guide?.id) || undefined,
guideTitle: firstText(guide?.title) || undefined,
guideText: guideText || undefined,
audioUrl: audioUrl || undefined,
audioDuration: resolveGuideAudioDuration(guide, source),
audioLanguage: supportedLanguage,
audioText: options.includeDetail && guideText ? guideText : undefined,
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
audioAvailable: audioAvailable || metadataSuggestsAudio,
audioStatus: source.audioStatus || undefined,
supportedLanguages: supportedLanguages.length ? supportedLanguages : undefined,
audioUnavailableReason: audioAvailable || metadataSuggestsAudio
? undefined
: '该讲解暂无已发布音频,当前提供图文讲解。',
playTargetType: targetType,
playTargetId: targetId || undefined
}
}
export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => ({
id: `track-${exhibit.guideContentId || exhibit.id}`,
exhibitId: exhibit.id,
hallId: exhibit.hallId,
title: exhibit.guideTitle || `${exhibit.name}讲解`,
summary: exhibit.guideText || exhibit.description,
mediaId: exhibit.audioUrl ? `media-${exhibit.guideContentId || exhibit.id}` : undefined,
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true,
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId
})
export const toBackendMediaAsset = (exhibit: MuseumExhibit): MediaAsset | null => {
if (!exhibit.audioUrl) return null
return {
id: `media-${exhibit.guideContentId || exhibit.id}`,
type: 'audio',
url: exhibit.audioUrl,
duration: exhibit.audioDuration,
language: exhibit.audioLanguage,
available: true
}
}
export const toBackendHall = (
hallId: string,
exhibits: MuseumExhibit[],
fallback?: MuseumHall | null
): MuseumHall => {
const first = exhibits[0]
return {
id: hallId,
name: firstText(first?.hallName, fallback?.name, '展厅'),
floorId: firstText(first?.floorId, fallback?.floorId) || undefined,
floorLabel: firstText(first?.floorLabel, fallback?.floorLabel, '楼层待补充'),
description: fallback?.description || '该免费讲解内容来自后端展品接口。',
image: fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: exhibits.length,
area: fallback?.area,
poiId: fallback?.poiId,
location: fallback?.location
}
}
export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall | null): MuseumHall => {
const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name)
const name = firstText(source.name, fallback?.name, source.hallCode, '展厅')
return {
id,
name,
floorId: stringifyId(source.floorId) || fallback?.floorId,
floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: typeof source.exhibitCount === 'number'
? source.exhibitCount
: fallback?.exhibitCount || 0,
outlineCount: normalizeNumber(source.outlineCount),
stopCount: normalizeNumber(source.stopCount),
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
area: fallback?.area,
poiId: stringifyId(source.poiId) || fallback?.poiId,
location: fallback?.location
}
}
export const toBackendGuideStop = (source: BackendGuideStop): ExplainGuideStop | null => {
const id = stringifyId(source.id)
if (!id) return null
return {
id,
name: firstText(source.name, `讲解点${id}`),
hallId: stringifyId(source.hallId) || undefined,
hallName: firstText(source.hallName) || undefined,
floorId: stringifyId(source.floorId) || undefined,
targetType: 'STOP',
targetId: id,
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
description: firstText(source.description) || undefined,
hasAudio: source.hasAudio === true,
poiId: stringifyId(source.poiId) || undefined,
mapX: normalizeNumber(source.mapX),
mapY: normalizeNumber(source.mapY),
outlineId: stringifyId(source.outlineId) || undefined,
outlineName: firstText(source.outlineName) || undefined,
sort: typeof source.sort === 'number' ? source.sort : undefined
}
}
export const groupGuideStopsByOutline = (
hallId: string,
stops: ExplainGuideStop[]
): ExplainBusinessUnit[] => {
const groupMap = new Map<string, ExplainGuideStop[]>()
const nameMap = new Map<string, string>()
stops
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.forEach((stop) => {
const key = stop.outlineId || `ungrouped-${hallId}`
const name = stop.outlineName || '其他讲解'
const items = groupMap.get(key) || []
items.push(stop)
groupMap.set(key, items)
if (!nameMap.has(key)) {
nameMap.set(key, name)
}
})
return Array.from(groupMap.entries()).map(([id, groupStops]) => ({
id,
name: nameMap.get(id) || '其他讲解',
hallId,
guideStopCount: groupStops.length,
stops: groupStops
}))
}