Compare commits

...

4 Commits

Author SHA1 Message Date
lyf
130f155bdf 接入编号讲解后端查询
Some checks failed
CI / verify (push) Has been cancelled
- 使用真实讲解编号解析接口替换本地演示映射
- 成功后复用现有详情页并自动播放讲解
- 增加查询状态、业务错误提示和相关测试

Made-with: Proma
2026-08-25 18:01:20 +08:00
cxk
00585a7b48 fix(h5): prevent internal asset host exposure
Some checks failed
CI / verify (push) Has been cancelled
Made-with: Proma
2026-08-15 19:01:55 +08:00
lyf
2edb6bd9f2 适配H5讲解统一详情接口
Some checks failed
CI / verify (push) Has been cancelled
2026-08-05 14:52:36 +08:00
lyf
c287ef3a2a 回退 H5 SDK 引擎强制同步 2026-08-03 23:27:02 +08:00
38 changed files with 1437 additions and 1229 deletions

View File

@@ -25,7 +25,7 @@ VITE_PUBLIC_LEGACY_AUDIO_HOST=
# Vite 开发服务器代理目标,只由 vite.config.ts 读取。
# /engine 由 public/engine 本地静态资源提供,保持和大屏端一致,不再代理到地图管理端。
DEV_PROXY_APP_API_TARGET=http://localhost:48080
DEV_PROXY_APP_API_TARGET=https://guide.whaoyue.com:4433
DEV_PROXY_ENGINE_TARGET=
DEV_PROXY_SDK_TARGET=
DEV_PROXY_MUSEUM_ASSETS_TARGET=http://1.92.206.90:9000

View File

@@ -0,0 +1,46 @@
# H5 讲解接口只读审计
- 审计时间2026-08-04Asia/Shanghai
- 目标:`https://guide.whaoyue.com:4433/app-api/gis/guide`
- 认证:无。五个正式 `GET` 接口均可匿名访问。
- 数据安全:仅执行读取请求;未调用删除、覆盖、批量修改或其他写操作。
## 接口契约
| 接口 | 方法 | 必填项 | 可选项 | 预期响应 |
| --- | --- | --- | --- | --- |
| `/catalog/halls` | GET | 无 | `lang` | `data` 为展厅数组 |
| `/catalog/halls/{hallId}/outlines` | GET | `hallId` | `lang` | `data` 为一级单元数组 |
| `/catalog/outlines/{outlineId}/stops` | GET | `outlineId` | `lang` | `data` 为讲解点摘要数组 |
| `/catalog/halls/{hallId}/stops/page` | GET | `hallId` | `pageNo``pageSize``lang` | `data.list``data.total` 分页对象 |
| `/stops/{stopId}` | GET | `stopId` | `version``standard` / `extended`,缺省 `standard` | 统一详情及当前版本的 `textVariants``audioTracks` |
## 执行记录
| 接口 | 场景 / 关键参数 | 预期 | 实际(状态码 / 业务 code / 摘要 / 耗时) | 结论 |
| --- | --- | --- | --- | --- |
| 展厅列表 | 正常:`lang=zh-CN` | 8 个展厅摘要 | `200 / 0 / array:8 / 263ms` | 通过 |
| 展厅列表 | 缺少可选 `lang` | 使用默认语言并返回数组 | `200 / 0 / array:8 / 115ms` | 通过 |
| 展厅列表 | 非法语言:`lang=ja-JP` | 文档未明确错误口径 | `200 / 0 / array:8 / 108ms` | 通过;服务端宽松接受,需补充文档 |
| 一级单元 | 正常:`hallId=715792102100832258``lang=zh-CN` | 一级单元数组 | `200 / 0 / array:3 / 95ms` | 通过 |
| 一级单元 | 错误非数值 hall ID | 参数错误 | `200 / 400 / 参数类型错误 / 110ms` | 通过 |
| 单元讲解点 | 文档示例单元:`outlineId=7467940240901013505` | 单元讲解点数组 | `200 / 0 / array:0 / 97ms` | 通过;该一级单元当前无讲解点 |
| 单元讲解点 | 详情所属单元:`outlineId=865546627916591104` | 包含当前讲解点的数组 | `200 / 0 / array:39 / 132ms` | 通过 |
| 单元讲解点 | 错误非数值 outline ID | 参数错误 | `200 / 400 / 参数类型错误 / 113ms` | 通过 |
| 展厅讲解点分页 | 正常:`pageNo=1&pageSize=20&lang=zh-CN` | 分页摘要 | `200 / 0 / list:20,total:39 / 197ms` | 通过 |
| 展厅讲解点分页 | 缺少可选分页参数 | 默认 `pageNo=1&pageSize=20` | `200 / 0 / list:20,total:39 / 101ms` | 通过 |
| 展厅讲解点分页 | 边界:`pageNo=999&pageSize=100` | 空页 | `200 / 0 / list:0,total:39 / 171ms` | 通过 |
| 展厅讲解点分页 | 非法:`pageNo=-1&pageSize=0` | 文档未明确错误口径 | `200 / 0 / list:20,total:39 / 113ms` | 通过;服务端回退默认值 |
| 展厅讲解点分页 | 超文档上限:`pageSize=101` | 文档声明最大 100 | `200 / 0 / list:39,total:39 / 120ms` | 偏差:服务端未限制 |
| 统一详情 | 正常:`stopId=865546647764037632&version=standard` | 标准版完整详情、正文与音轨 | `200 / 0 / version:standard,textVariants:3,audioTracks:5,recommendedTrackCode:standard.zh-CN.female / 150ms` | 通过 |
| 统一详情 | 版本边界:`version=extended` | 拓展版当前版本详情 | `200 / 0 / version:extended,textVariants:0,audioTracks:0 / 107ms` | 通过;该讲解对象暂无拓展版内容 |
| 统一详情 | 缺少可选版本 | 按 `standard` 返回 | `200 / 0 / version:standard,textVariants:3,audioTracks:5 / 110ms` | 通过 |
| 统一详情 | 非法版本:`version=unsupported` | 业务错误,不静默降级 | `200 / 1020005006 / 讲解点详情版本仅支持 standard / extended / 118ms` | 通过 |
| 统一详情 | 非数值错误 ID | 参数错误 | `200 / 400 / 参数类型错误 / 93ms` | 通过 |
| 统一详情 | 数值但不存在 ID`999999999999999999` | `GUIDE_STOP_NOT_EXISTS` 业务错误 | `200 / 1020005000 / 导览讲解点不存在 / 117ms` | 通过 |
## 数据关联结论
展厅的 3 个一级单元当前均为 `stopCount=0`,而详情讲解点 `865546647764037632` 实际属于单元 `865546627916591104`,该单元可返回 39 条讲解点。这说明一级单元目录与讲解点关联仍存在后端数据不一致。H5 主流程保持为 `展厅 -> 讲解对象 -> 统一详情`,不以一级单元作为主导航。
统一详情即使参数错误也使用 HTTP `200`,客户端必须结合业务 `code` 判断结果。语言和性别均由当前版本的详情数组在本地切换;切换标准版与拓展版时才重新请求统一详情并传入 `version`

View File

@@ -8,14 +8,11 @@
"node": ">=20 <25"
},
"scripts": {
"sync:sdk-engine": "node scripts/sync-sdk-engine.cjs",
"predev:h5": "npm run sync:sdk-engine",
"dev:h5": "uni -p h5 --mode development",
"predev:test:h5": "npm run sync:sdk-engine",
"dev:test:h5": "uni -p h5 --mode test",
"build:test:h5": "npm run sync:sdk-engine && uni build -p h5 --mode test && node scripts/finalize-h5-build.cjs --allow-placeholder",
"build:h5": "npm run sync:sdk-engine && uni build -p h5 --mode production && node scripts/finalize-h5-build.cjs --require-tencent-map-key",
"build:h5:no-minify": "npm run sync:sdk-engine && uni build -p h5 --mode production --minify false && node scripts/finalize-h5-build.cjs --require-tencent-map-key",
"build:test:h5": "uni build -p h5 --mode test && node scripts/finalize-h5-build.cjs --allow-placeholder",
"build:h5": "uni build -p h5 --mode production && node scripts/finalize-h5-build.cjs --require-tencent-map-key",
"build:h5:no-minify": "uni build -p h5 --mode production --minify false && node scripts/finalize-h5-build.cjs --require-tencent-map-key",
"dev:mp-weixin": "uni -p mp-weixin --mode development",
"dev:test:mp-weixin": "uni -p mp-weixin --mode test",
"build:test:mp-weixin": "uni build -p mp-weixin --mode test",

View File

@@ -10,6 +10,7 @@ const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
const badPatterns = [
'http://1.92.206.90:9000',
'1.92.206.90',
'172.20.14.21',
'guide.whaoyue.com',
placeholder
]

View File

@@ -1,52 +0,0 @@
const fs = require('fs')
const path = require('path')
const projectRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(projectRoot, '..')
const sourceDir = path.join(repoRoot, 'sgs-map-sdk-release', 'engine')
const targetDir = path.join(projectRoot, 'public', 'engine')
if (!fs.existsSync(sourceDir)) {
throw new Error(`SDK Engine source not found: ${sourceDir}`)
}
const publicDir = path.join(projectRoot, 'public')
const resolvedTarget = path.resolve(targetDir)
if (!resolvedTarget.startsWith(path.resolve(publicDir) + path.sep)) {
throw new Error(`Refusing to write outside public directory: ${resolvedTarget}`)
}
const rewriteTextFiles = (dir) => {
const replacements = [
['http://1.92.206.90:9000/museum-assets', '/museum-assets'],
['http://1.92.206.90:9000/tts-audio', '/tts-audio'],
['http://1.92.206.90:9000', 'http://legacy-minio.local:9000'],
['https://1.92.206.90:9000', 'https://legacy-minio.local:9000'],
['https://guide.whaoyue.com/museum-assets', '/museum-assets'],
['https://guide.whaoyue.com/tts-audio', '/tts-audio'],
['https://guide.whaoyue.com/app-api', '/app-api']
]
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, entry.name)
if (entry.isDirectory()) {
rewriteTextFiles(filePath)
continue
}
if (!entry.isFile() || !/\.(?:html|js|mjs|json|css|map)$/i.test(entry.name)) continue
let text = fs.readFileSync(filePath, 'utf8')
const original = text
for (const [from, to] of replacements) {
text = text.split(from).join(to)
}
if (text !== original) fs.writeFileSync(filePath, text, 'utf8')
}
}
fs.rmSync(targetDir, { recursive: true, force: true })
fs.mkdirSync(path.dirname(targetDir), { recursive: true })
fs.cpSync(sourceDir, targetDir, { recursive: true })
rewriteTextFiles(targetDir)
console.log(`Synced SDK Engine: ${sourceDir} -> ${targetDir}`)

View File

@@ -62,10 +62,7 @@ export interface ExplainGuideStopCatalogItem {
description?: string
audioStatus?: string
hasAudio?: boolean
hasTextRecord?: boolean
guideLevel?: string
playTargetType?: 'ITEM' | 'STOP'
playTargetId?: string
}
const props = withDefaults(defineProps<{
@@ -144,7 +141,7 @@ const handleImageError = (stopId: string) => {
}
const availabilityLabel = (stop: ExplainGuideStopCatalogItem) => (
stop.audioStatus === 'READY' || stop.hasAudio ? '讲解' : stop.hasTextRecord ? '图文' : '暂无内容'
stop.audioStatus === 'READY' || stop.hasAudio ? '讲解' : '图文'
)
const requestMore = () => { if (props.hasMore && !props.loading && !props.loadingMore && !props.loadMoreError) emit('retryMore') }
const handleScroll = (event: Event) => { const target = event.target as HTMLElement | null; if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore() }

View File

@@ -33,6 +33,24 @@
</view>
<template v-else>
<view v-if="stage === 'hall'" class="code-entry-section">
<button class="code-entry-button" @tap="openCodeEntry">
<view class="code-entry-icon" aria-hidden="true">
<view class="headphone-band" />
<view class="headphone-ear left" />
<view class="headphone-ear right" />
</view>
<view class="code-entry-copy">
<text class="code-entry-title">编号讲解</text>
<text class="code-entry-desc">输入展品旁的编号直接收听讲解</text>
</view>
</button>
</view>
<view v-if="stage === 'hall'" class="hall-section-heading">
<text class="hall-section-title">按展厅选择</text>
</view>
<view v-if="stage === 'hall' && filteredHalls.length" class="hall-list hall-overview-list">
<view
v-for="(hall, index) in filteredHalls"
@@ -178,6 +196,45 @@
</view>
</template>
</scroll-view>
<view v-if="codeEntryVisible" class="code-entry-overlay" @tap="closeCodeEntry">
<view class="code-entry-sheet" @tap.stop>
<view class="code-entry-content">
<text class="code-entry-sheet-title">输入讲解编号</text>
<text class="code-entry-sheet-desc">请输入展品旁标注的 4 位编号</text>
<view class="code-digit-fields" :class="{ error: codeEntryError }">
<view
v-for="index in 4"
:key="index"
class="code-digit-cell"
:class="{ active: codeEntryValue.length === index - 1 }"
>
<text class="code-digit-value">{{ codeEntryValue[index - 1] || '' }}</text>
</view>
<input
v-if="codeEntryVisible"
class="code-entry-native-input"
:value="codeEntryValue"
type="number"
inputmode="numeric"
confirm-type="go"
maxlength="4"
:focus="codeEntryVisible && !codeSubmitting"
:disabled="codeSubmitting"
@input="handleCodeEntryInput"
@confirm="submitCodeEntry"
/>
</view>
<text v-if="codeEntryError" class="code-entry-error">{{ codeEntryError }}</text>
<text v-else class="code-entry-hint">请输入展品标签上的四位编号</text>
<button
class="code-entry-action primary"
:disabled="codeEntryValue.length !== 4 || codeSubmitting"
@tap="submitCodeEntry"
>{{ codeSubmitting ? '正在查询' : '开始讲解' }}</button>
</view>
</view>
</view>
</view>
</template>
@@ -210,8 +267,6 @@ export interface ExplainGuideStopSelectItem {
hasAudio?: boolean
audioStatus?: string
guideLevel?: string
playTargetType?: 'ITEM' | 'STOP'
playTargetId?: string
}
export interface ExplainBusinessUnitSelectItem {
@@ -236,6 +291,7 @@ const props = withDefaults(defineProps<{
error?: string
loadMoreError?: string
forceInternalHeader?: boolean
codeSubmitting?: boolean
}>(), {
halls: () => [],
businessUnits: () => [],
@@ -247,11 +303,13 @@ const props = withDefaults(defineProps<{
hasMore: false,
error: '',
loadMoreError: '',
forceInternalHeader: false
forceInternalHeader: false,
codeSubmitting: false
})
const emit = defineEmits<{
hallClick: [hallId: string]
codeSubmit: [code: string]
businessUnitClick: [unitId: string]
guideStopClick: [stop: ExplainGuideStopSelectItem]
back: []
@@ -263,6 +321,9 @@ const HALL_PREVIEW_BASE = '/static/icons/halls/portal-icons'
const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>())
const codeEntryVisible = ref(false)
const codeEntryValue = ref('')
const codeEntryError = ref('')
const hallPreviewMap: Record<string, string> = {
宇宙厅: `${HALL_PREVIEW_BASE}/universe.png`,
@@ -294,6 +355,7 @@ const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUs
const headerTitle = computed(() => {
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
if (props.stage === 'unit') return props.selectedHallName || '讲解单元'
if (codeEntryVisible.value) return '编号讲解'
return '免费讲解'
})
const loadingTitle = computed(() => {
@@ -357,6 +419,45 @@ const handleHallClick = (hallId: string) => {
emit('hallClick', hallId)
}
const openCodeEntry = () => {
codeEntryError.value = ''
codeEntryVisible.value = true
}
const closeCodeEntry = () => {
codeEntryVisible.value = false
codeEntryValue.value = ''
codeEntryError.value = ''
}
const handleCodeEntryInput = (event: Event) => {
const inputEvent = event as Event & { detail?: { value?: string } }
codeEntryValue.value = (inputEvent.detail?.value || '')
.replace(/\D/g, '')
.slice(0, 4)
codeEntryError.value = ''
}
const submitCodeEntry = () => {
if (props.codeSubmitting) return
const code = codeEntryValue.value
if (code.length !== 4) {
codeEntryError.value = '请输入完整的 4 位讲解编号'
return
}
emit('codeSubmit', code)
}
const showCodeEntryError = (message: string) => {
codeEntryError.value = message
}
const completeCodeEntry = () => {
closeCodeEntry()
}
defineExpose({ showCodeEntryError, completeCodeEntry })
const handleBusinessUnitClick = (unitId: string) => {
emit('businessUnitClick', unitId)
}
@@ -384,6 +485,10 @@ const handleScroll = (event: Event) => {
}
const handleBack = () => {
if (codeEntryVisible.value) {
closeCodeEntry()
return
}
emit('back')
}
</script>
@@ -407,6 +512,64 @@ const handleBack = () => {
padding: 76px 16px 16px;
}
.code-entry-section {
padding-bottom: 10px;
}
.code-entry-button {
position: relative;
width: 100%;
min-height: 82px;
margin: 0;
padding: 13px 72px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #171713;
border: 1px solid #cfd4c7;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 3px 10px rgba(36, 49, 42, 0.06);
}
.code-entry-button::after { border: 0; }
.code-entry-button:active { background: #f0f3e8; }
.code-entry-icon { position: absolute; left: 28px; top: 50%; width: 44px; height: 44px; border-radius: 8px; background: #e0e100; transform: translateY(-50%); }
.headphone-band { position: absolute; left: 10px; top: 9px; width: 24px; height: 23px; box-sizing: border-box; border: 3px solid #171713; border-bottom: 0; border-radius: 14px 14px 0 0; }
.headphone-ear { position: absolute; top: 25px; width: 7px; height: 11px; border-radius: 3px; background: #171713; }
.headphone-ear.left { left: 8px; }
.headphone-ear.right { right: 8px; }
.code-entry-copy { width: 100%; min-width: 0; text-align: center; }
.code-entry-title, .code-entry-desc { display: block; }
.code-entry-title { font-size: 17px; line-height: 23px; font-weight: 700; color: #171713; }
.code-entry-desc { margin-top: 3px; font-size: 12px; line-height: 18px; color: #68725d; }
.hall-section-heading { height: 30px; display: flex; align-items: center; justify-content: center; }
.hall-section-title { font-size: 13px; line-height: 19px; font-weight: 600; color: #68725d; }
.code-entry-overlay { position: absolute; z-index: 3000; top: 64px; right: 0; bottom: 0; left: 0; background: #f7f9f2; }
.code-entry-sheet { position: absolute; inset: 0; padding: 24px 24px calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }
.code-entry-content { width: min(100%, 330px); min-height: 100%; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; box-sizing: border-box; transform: translateY(-28px); }
.code-entry-sheet-title, .code-entry-sheet-desc, .code-entry-error, .code-entry-demo { display: block; text-align: center; }
.code-entry-sheet-title { font-size: 22px; line-height: 30px; font-weight: 700; color: #171713; }
.code-entry-sheet-desc { margin-top: 8px; font-size: 14px; line-height: 20px; color: #68725d; }
.code-entry-action::after { border: 0; }
.code-digit-fields { position: relative; width: 100%; margin-top: 32px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.code-digit-cell { height: 68px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; border: 1.5px solid #aeb5a5; border-radius: 6px; background: #ffffff; }
.code-digit-cell.active { border-color: #777d00; box-shadow: 0 0 0 1px #e0e100 inset; }
.code-digit-fields.error .code-digit-cell { border-color: #b63f3f; }
.code-digit-value { font-size: 30px; line-height: 36px; font-weight: 700; color: #171713; }
.code-entry-native-input { position: absolute; inset: 0; width: 100%; height: 68px; opacity: 0.01; color: transparent; caret-color: transparent; }
.code-entry-error, .code-entry-hint { min-height: 19px; margin-top: 10px; font-size: 12px; line-height: 19px; }
.code-entry-error { color: #a62f2f; }
.code-entry-hint { color: #7a8271; }
.code-entry-action { width: 100%; height: 48px; margin: 28px 0 0; padding: 0 12px; font-size: 15px; line-height: 48px; font-weight: 700; border: 0; border-radius: 6px; }
.code-entry-action.primary { color: #171713; background: #e0e100; }
.code-entry-action.primary[disabled] { color: #909489; background: #e4e6df; opacity: 1; }
.explain-hall-select.host-navigation .code-entry-overlay { top: 0; }
.explain-scroll.stop-stage {
padding-bottom: 0;
}
@@ -908,13 +1071,23 @@ const handleBack = () => {
}
.explain-scroll.hall-stage {
padding: 88px 16px 24px;
padding: 88px 16px 12px;
overflow: hidden;
}
.hall-overview-list {
// Keep the eight-hall overview balanced on tall phones while preserving a
// fixed minimum row height for compact screens.
grid-auto-rows: max(138px, calc((100vh - 148px) / 4));
grid-template-rows: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
height: calc(100vh - 222px);
gap: 10px 12px;
}
.hall-overview-card {
min-height: 0;
}
.explain-hall-select.host-navigation .hall-overview-list {
height: calc(100vh - 146px);
}
.hall-overview-card {

View File

@@ -4,19 +4,20 @@ import type {
AudioItem
} from '@/components/audio/AudioPlayer.vue'
import {
audioPlayInfoRepository,
audioReasonToText,
type AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import type { AudioPlayTargetType } from '@/domain/museum'
import type { AudioPlayTargetType, MuseumGuideAudioOption } from '@/domain/museum'
import { resolveGuideAudioOption } from '@/domain/guideAudioOptions'
export interface GlobalAudioSource {
exhibitId?: string
stopId?: string
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage | string
channelCode?: string
voiceGender?: 'male' | 'female'
audioOptions?: MuseumGuideAudioOption[]
detailRoute?: string
title?: string
}
@@ -192,14 +193,14 @@ const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguag
}
const toSwitchedAudioItem = (
playInfo: Awaited<ReturnType<typeof audioPlayInfoRepository.getPlayInfo>>,
audioOption: MuseumGuideAudioOption,
lang: AudioLanguage
): AudioItem => ({
id: `play-${playInfo.audioId || `${playInfo.targetType}-${playInfo.targetId}-${lang}`}`,
name: currentAudio.value?.name || currentSource.value?.title || playInfo.title || '讲解音频',
audioUrl: playInfo.playUrl || '',
id: `channel-${audioOption.channelCode}`,
name: currentAudio.value?.name || currentSource.value?.title || audioOption.displayName || '讲解音频',
audioUrl: audioOption.playUrl,
image: currentAudio.value?.image,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : currentAudio.value?.duration,
duration: audioOption.duration || currentAudio.value?.duration,
language: lang,
supportedLanguages: currentAudio.value?.supportedLanguages
})
@@ -292,7 +293,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
}
const source = currentSource.value
if (!source?.targetType || !source.targetId) {
if (!source?.audioOptions?.length) {
error.value = '当前讲解暂不支持语言切换'
showToast(error.value)
return false
@@ -318,20 +319,19 @@ const switchLanguage = async (lang: AudioLanguage) => {
}
try {
const playInfo = await audioPlayInfoRepository.getPlayInfo({
targetType: source.targetType,
targetId: source.targetId,
const audioOption = resolveGuideAudioOption(
source.audioOptions,
lang,
refresh: true
})
source.voiceGender || 'female'
)
if (!playInfo.playable || !playInfo.playUrl) {
if (!audioOption?.playUrl) {
stopAudioElement()
currentSource.value = nextSource
currentAudio.value = currentAudio.value
? {
...currentAudio.value,
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
@@ -343,14 +343,19 @@ const switchLanguage = async (lang: AudioLanguage) => {
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = audioReasonToText(playInfo.reason || 'NO_PUBLISHED_AUDIO')
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
}
currentSource.value = nextSource
return await play(toSwitchedAudioItem(playInfo, lang), {
source: nextSource,
const nextTrackSource: GlobalAudioSource = {
...nextSource,
channelCode: audioOption.channelCode,
voiceGender: audioOption.gender
}
currentSource.value = nextTrackSource
return await play(toSwitchedAudioItem(audioOption, lang), {
source: nextTrackSource,
retryOnError: retryOnError || undefined,
displayMode: preservedMode
})
@@ -361,7 +366,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
if (currentAudio.value) {
currentAudio.value = {
...currentAudio.value,
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
@@ -462,14 +467,16 @@ const unregisterHost = (hostId: string) => {
}
}
const isCurrentSource = (source: Pick<GlobalAudioSource, 'targetType' | 'targetId' | 'lang' | 'channelCode'>) => {
const isCurrentSource = (source: Pick<GlobalAudioSource, 'stopId' | 'targetType' | 'targetId' | 'lang' | 'channelCode'>) => {
if (!currentAudio.value || !currentSource.value) return false
return Boolean(
source.targetType
(source.stopId
? currentSource.value.stopId === source.stopId || currentSource.value.targetId === source.stopId
: source.targetType
&& source.targetId
&& currentSource.value.targetType === source.targetType
&& currentSource.value.targetId === source.targetId
&& currentSource.value.targetId === source.targetId)
&& (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang)
&& (!source.channelCode || currentSource.value.channelCode === source.channelCode)
)

View File

@@ -100,9 +100,7 @@ export const dataSourceConfig = {
audioApiTimeoutMs: normalizeTimeout(
import.meta.env.VITE_AUDIO_API_TIMEOUT_MS || import.meta.env.VITE_EXPLAIN_API_TIMEOUT_MS,
defaultExplainRequestTimeoutMs
),
publicSameOriginAssetHost: import.meta.env.VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST?.trim() || '',
publicLegacyAudioHost: import.meta.env.VITE_PUBLIC_LEGACY_AUDIO_HOST?.trim() || ''
)
}
export const isSgsSdkMode = () => dataSourceConfig.guideDataMode === 'sdk'

View File

@@ -158,9 +158,14 @@ export interface BackendCatalogStopItem {
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
hasTextRecord?: boolean | null
playTargetType?: string | null
playTargetId?: string | number | 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 {
@@ -339,8 +344,6 @@ export const toCatalogGuideStop = (
const id = stringifyId(source.stopId) || stringifyId(source.id)
if (!id) return null
const playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP'
const playTargetId = stringifyId(source.playTargetId) || id
const imageStatus = firstText(source.imageStatus) || 'MISSING'
const canUseStopImage = imageStatus === 'READY'
@@ -350,8 +353,6 @@ export const toCatalogGuideStop = (
hallId: hall?.id,
hallName: hall?.name,
floorId: stringifyId(source.floorId) || hall?.floorId,
targetType: playTargetType,
targetId: playTargetId,
coverImageUrl: canUseStopImage
? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
: undefined,
@@ -359,7 +360,6 @@ export const toCatalogGuideStop = (
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
hasTextRecord: source.hasTextRecord === true,
poiId: stringifyId(source.poiId) || undefined,
mapX: normalizeNumber(source.mapX),
mapY: normalizeNumber(source.mapY),
@@ -373,8 +373,27 @@ export const toCatalogGuideStop = (
linkedExhibits: (source.linkedExhibits || [])
.map(toCatalogLinkedExhibitSummary)
.filter(Boolean) as NonNullable<ExplainGuideStop['linkedExhibits']>,
playTargetType,
playTargetId
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']>
}
}
@@ -404,15 +423,15 @@ export const toCatalogMuseumExhibitFromStop = (
audioAvailable: audioReady,
audioStatus: stop.audioStatus,
supportedLanguages: stop.supportedLanguages,
audioHasText: stop.hasTextRecord,
// 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,
playTargetType: stop.playTargetType || stop.targetType || 'STOP',
playTargetId: stop.playTargetId || stop.targetId || stop.id
resolvedStopId: stop.id
}
}

View File

@@ -9,6 +9,54 @@ import {
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
export type GuideAudioVersion = 'standard' | 'extended'
export interface BackendGuideTextVariant {
version?: string | null
languageCode?: string | null
languageName?: string | null
text?: string | null
textLength?: number | string | null
textHash?: string | null
}
export interface BackendGuideAudioTrack {
version?: string | null
languageCode?: string | null
languageName?: string | null
gender?: string | null
displayName?: string | null
playUrl?: string | null
duration?: number | string | null
format?: string | null
isDefault?: boolean | null
}
export interface BackendGuideStopDetail {
id?: string | number | null
version?: string | null
outlineId?: string | number | null
poiId?: string | number | null
floorId?: string | number | null
mapX?: number | string | null
mapY?: number | string | null
name?: string | null
description?: string | null
coverImageUrl?: string | null
galleryUrls?: string | string[] | null
imageStatus?: string | null
linkedExhibits?: BackendGuideStopLinkedExhibit[] | null
linkedExhibitCount?: number | string | null
isSharedStop?: boolean | null
recommendedTrackCode?: string | null
textVariants?: BackendGuideTextVariant[] | null
audioTracks?: BackendGuideAudioTrack[] | null
hasText?: boolean | null
hasAudio?: boolean | null
textVariantCount?: number | string | null
audioTrackCount?: number | string | null
}
export interface BackendGuideStopLinkedExhibit {
id?: string | number | null
name?: string | null
@@ -34,6 +82,44 @@ export interface BackendGuideAudioOption {
sortOrder?: number | string | null
}
export interface GuideTextVariant {
version: GuideAudioVersion
languageCode: GuideAudioLanguage
languageName?: string
text?: string
textLength?: number
textHash?: string
}
export interface GuideAudioTrack extends MuseumGuideAudioOption {
version: GuideAudioVersion
}
export interface GuideStopDetail {
id: string
version: GuideAudioVersion
outlineId?: string
poiId?: string
floorId?: string
mapX?: number
mapY?: number
name: string
description?: string
coverImageUrl?: string
galleryUrls: string[]
imageStatus: 'READY' | 'MISSING' | string
linkedExhibits: GuideStopLinkedExhibit[]
linkedExhibitCount?: number
isSharedStop: boolean
recommendedTrackCode?: string
textVariants: GuideTextVariant[]
audioTracks: GuideAudioTrack[]
hasText: boolean
hasAudio: boolean
textVariantCount: number
audioTrackCount: number
}
export interface BackendGuideStopInfo {
available?: boolean
targetType?: string | null
@@ -214,6 +300,57 @@ const normalizeAudioGender = (value: string | null | undefined): GuideAudioGende
return normalized === 'male' || normalized === 'female' ? normalized : null
}
const normalizeAudioVersion = (value: string | null | undefined): GuideAudioVersion => (
value?.trim().toLowerCase() === 'extended' ? 'extended' : 'standard'
)
const normalizeTextVariants = (items: BackendGuideTextVariant[] | null | undefined): GuideTextVariant[] => (
(items || [])
.map<GuideTextVariant | null>((item) => {
const text = item.text?.trim() || undefined
const languageCode = normalizeGuideAudioLanguage(item.languageCode)
if (!text) return null
return {
version: normalizeAudioVersion(item.version),
languageCode,
languageName: item.languageName?.trim() || undefined,
text,
textLength: normalizeNumber(item.textLength),
textHash: item.textHash?.trim() || undefined
}
})
.filter(Boolean) as GuideTextVariant[]
)
const trackCodeFor = (version: GuideAudioVersion, languageCode: GuideAudioLanguage, gender: GuideAudioGender) => (
`${version}.${languageCode}.${gender}`
)
const normalizeAudioTracks = (items: BackendGuideAudioTrack[] | null | undefined): GuideAudioTrack[] => (
(items || [])
.map<GuideAudioTrack | null>((item) => {
const languageCode = normalizeGuideAudioLanguage(item.languageCode)
const gender = normalizeAudioGender(item.gender)
const playUrl = normalizeSameOriginPublicUrl(item.playUrl)
if (!gender || !playUrl) return null
const version = normalizeAudioVersion(item.version)
return {
channelCode: trackCodeFor(version, languageCode, gender),
displayName: item.displayName?.trim() || undefined,
version,
languageCode,
languageName: item.languageName?.trim() || undefined,
gender,
playUrl,
duration: normalizeNumber(item.duration),
format: item.format?.trim() || undefined,
isDefault: item.isDefault === true,
sortOrder: undefined
}
})
.filter(Boolean) as GuideAudioTrack[]
)
const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefined): MuseumGuideAudioOption[] => (
(items || [])
.map<MuseumGuideAudioOption | null>((item) => {
@@ -240,6 +377,43 @@ const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefin
.filter(Boolean) as MuseumGuideAudioOption[]
)
export const toGuideStopDetail = (source: BackendGuideStopDetail): GuideStopDetail => {
const id = stringifyId(source.id)
if (!id) throw new Error('讲解点详情缺少 id')
const imageStatus = source.imageStatus || 'MISSING'
const canUseStopImages = imageStatus === 'READY'
const textVariants = normalizeTextVariants(source.textVariants)
const audioTracks = normalizeAudioTracks(source.audioTracks)
const linkedExhibits = normalizeLinkedExhibits(source.linkedExhibits)
return {
id,
version: normalizeAudioVersion(source.version),
outlineId: stringifyId(source.outlineId) || undefined,
poiId: stringifyId(source.poiId) || undefined,
floorId: stringifyId(source.floorId) || undefined,
mapX: normalizeNumber(source.mapX),
mapY: normalizeNumber(source.mapY),
name: source.name?.trim() || '讲解内容',
description: source.description?.trim() || undefined,
coverImageUrl: canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined,
galleryUrls: canUseStopImages ? parseGalleryUrls(source.galleryUrls) : [],
imageStatus,
linkedExhibits,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount) ?? linkedExhibits.length,
isSharedStop: source.isSharedStop === true,
recommendedTrackCode: source.recommendedTrackCode?.trim() || undefined,
textVariants,
audioTracks,
hasText: source.hasText === true || textVariants.length > 0,
hasAudio: source.hasAudio === true || audioTracks.length > 0,
textVariantCount: normalizeNumber(source.textVariantCount) ?? textVariants.length,
audioTrackCount: normalizeNumber(source.audioTrackCount) ?? audioTracks.length
}
}
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
if (!value) return []
if (Array.isArray(value)) {

View File

@@ -259,10 +259,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
if (inflight) return inflight
const promise = (async () => {
const params = new URLSearchParams({
includeChildren: 'true',
lang: catalogLang()
})
const params = new URLSearchParams({ lang: catalogLang() })
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
const outlines = requireArrayData(response, '讲解单元目录加载失败')
@@ -511,9 +508,20 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
if (inflight) return inflight
const promise = (async () => {
const outlines = await this.requestCatalogOutlinesByHall(normalizedHallId)
const [halls, outlines] = await Promise.all([
this.requestCatalogHalls(),
this.requestCatalogOutlinesByHall(normalizedHallId)
])
// The outline response carries authoritative counts. Stops are loaded only after unit selection.
const units = toCatalogBusinessUnits(normalizedHallId, outlines, [])
const hall = halls.find((item) => item.id === normalizedHallId)
if ((hall?.stopCount || 0) > 0 && units.length > 0 && units.every((unit) => unit.guideStopCount === 0)) {
console.warn('一级单元与讲解点关联异常,一级单元仅作为兼容入口:', {
hallId: normalizedHallId,
hallStopCount: hall?.stopCount,
unitStopCounts: units.map((unit) => ({ id: unit.id, stopCount: unit.guideStopCount }))
})
}
this.setCached(this.businessUnitCache, key, units)
return units
})().catch((error) => {

View File

@@ -160,6 +160,15 @@ export interface MuseumGuideAudioOption {
sortOrder?: number
}
export interface MuseumGuideTextVariant {
version: 'standard' | 'extended'
languageCode: string
languageName?: string
text?: string
textLength?: number
textHash?: string
}
export interface MuseumExhibit {
id: string
name: string
@@ -196,6 +205,11 @@ export interface MuseumExhibit {
audioStatus?: string
supportedLanguages?: string[]
audioOptions?: MuseumGuideAudioOption[]
textVariants?: MuseumGuideTextVariant[]
recommendedTrackCode?: string
guideVersion?: 'standard' | 'extended'
textVariantCount?: number
audioTrackCount?: number
audioVariants?: Record<string, {
title?: string
text?: string
@@ -262,6 +276,8 @@ export interface ExplainGuideStop {
}>
playTargetType?: AudioPlayTargetType
playTargetId?: string
stopId?: string
audioOptions?: MuseumGuideAudioOption[]
}
export interface ExplainGuideStopPage {

2
src/env.d.ts vendored
View File

@@ -25,8 +25,6 @@ interface ImportMetaEnv {
readonly VITE_SGS_SDK_ORIGIN?: string
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
readonly VITE_TENCENT_MAP_KEY?: string
readonly VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST?: string
readonly VITE_PUBLIC_LEGACY_AUDIO_HOST?: string
}
interface ImportMeta {

View File

@@ -172,10 +172,7 @@ import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type {
AudioPlayTargetType,
GuideAudioGender
} from '@/domain/museum'
import type { GuideAudioGender } from '@/domain/museum'
import type {
AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
@@ -222,12 +219,11 @@ const activeTopTab = ref<GuideTopTab>('explain')
const retryingAudio = ref(false)
const languageSwitchLoading = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
const selectedAudioGender = ref<GuideAudioGender>('male')
const selectedAudioGender = ref<GuideAudioGender>('female')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{
exhibitId: string
targetType?: AudioPlayTargetType
targetId?: string
stopId: string
hallId?: string
hallName?: string
floorId?: string
@@ -250,14 +246,23 @@ const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'en-US': false
})
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const shouldAutoplayOnEntry = ref(false)
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
)
const detailAudioVersion = computed<'standard' | 'extended'>(() => (
exhibit.value.audio.version === 'extended' ? 'extended' : 'standard'
))
const activeDetailAudioOptions = computed(() => (
(exhibit.value.audio.audioOptions || []).filter((option) => (
!option.version || option.version === detailAudioVersion.value
))
))
const supportedDetailLanguages = computed(() => (
resolveGuideAudioLanguages(
exhibit.value.audio.audioOptions,
activeDetailAudioOptions.value,
exhibit.value.audio.supportedLanguages
).filter(isAudioLanguage)
))
@@ -316,7 +321,7 @@ const heroSubtitle = computed(() => [
detailMeta.value
].filter(Boolean).join(' · '))
const selectedDetailAudioOption = computed(() => resolveGuideAudioOption(
exhibit.value.audio.audioOptions,
activeDetailAudioOptions.value,
selectedAudioLanguage.value,
selectedAudioGender.value
))
@@ -325,7 +330,7 @@ const currentDetailAudioGender = computed(() => (
|| resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value)
))
const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender(
exhibit.value.audio.audioOptions,
activeDetailAudioOptions.value,
selectedAudioLanguage.value
))
const detailAudioVoiceLabel = computed(() => {
@@ -337,21 +342,18 @@ const selectedDetailAudioAvailable = computed(() => (
|| exhibit.value.audio.status === 'playable'
))
const currentAudioTarget = computed(() => ({
targetType: exhibit.value.audio.playTargetType || 'ITEM',
targetId: exhibit.value.audio.playTargetId || exhibit.value.id,
stopId: exhibit.value.audio.stopId || exhibit.value.id,
lang: selectedAudioLanguage.value,
channelCode: selectedDetailAudioOption.value?.channelCode
}))
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
const isCurrentDetailAudioTarget = computed(() => {
const source = globalAudioPlayer.currentSource.value
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
const stopId = exhibit.value.audio.stopId || exhibit.value.id
return Boolean(
globalAudioPlayer.currentAudio.value
&& source?.targetType === targetType
&& source.targetId === targetId
&& (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId))
)
})
let detailAudioClosedOnExit = false
@@ -418,8 +420,8 @@ const isCurrentDetailAudioError = computed(() => {
const source = globalAudioPlayer.currentSource.value
return Boolean(
globalAudioPlayer.error.value
&& source?.targetType === currentAudioTarget.value.targetType
&& source.targetId === currentAudioTarget.value.targetId
&& (source?.stopId === currentAudioTarget.value.stopId
|| (source?.targetType === 'STOP' && source.targetId === currentAudioTarget.value.stopId))
)
})
const isDetailAudioLoading = computed(() => (
@@ -451,25 +453,8 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => {
}
}
const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({
id: viewModel.id,
name: viewModel.title,
hallId: viewModel.hallId,
hallName: viewModel.hallName,
floorId: viewModel.floorId,
floorLabel: viewModel.floorLabel,
image: viewModel.coverImages[0],
description: viewModel.summary,
guideText: viewModel.summary,
audioLanguage: viewModel.audio.language,
audioHasText: viewModel.audio.hasText,
audioOptions: viewModel.audio.audioOptions,
playTargetType: viewModel.audio.playTargetType,
playTargetId: viewModel.audio.playTargetId
})
const loadFullDetailTextForLanguage = async (
request: NonNullable<typeof detailEntryRequest.value>,
_request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
@@ -479,21 +464,15 @@ const loadFullDetailTextForLanguage = async (
}
try {
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
...request,
lang
}))
if (viewModel.audio.hasText) {
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
if (selection.available) {
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
return
}
}
applyDetailText(lang, viewModel.body || viewModel.summary)
const viewModel = preferredViewModel || exhibit.value
const variants = viewModel.audio.textVariants || []
const version = viewModel.audio.version || 'standard'
const detailText = variants.find((variant) => (
variant.version === version && variant.languageCode === lang
))?.text || (lang === 'yue-HK'
? variants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN')?.text
: undefined)
applyDetailText(lang, detailText || viewModel.summary)
} catch (error) {
console.warn('讲解正文加载失败:', lang, error)
applyDetailText(lang)
@@ -517,8 +496,12 @@ const loadExplainDetail = async (
heroImageIndex.value = 0
exhibit.value = toExplainDetailPageViewModel(exhibitData)
const resolvedLanguage = resolveSupportedDetailLanguage(lang)
const recommended = exhibit.value.audio.audioOptions?.find((option) => (
option.channelCode === exhibit.value.audio.recommendedTrackCode && option.version === detailAudioVersion.value
))
const resolvedLanguage = resolveSupportedDetailLanguage(recommended?.languageCode as AudioLanguage || lang)
selectedAudioLanguage.value = resolvedLanguage
selectedAudioGender.value = recommended?.gender || resolveGuideAudioGender(resolvedLanguage, 'female')
if (resolvedLanguage !== lang) {
replaceDetailRouteLanguage(resolvedLanguage)
@@ -532,29 +515,25 @@ const loadExplainDetail = async (
onLoad(async (options: any = {}) => {
detailAudioClosedOnExit = false
selectedAudioGender.value = 'male'
selectedAudioGender.value = 'female'
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
activeTopTab.value = tab
}
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
if (!exhibitId) {
const rawStopId = Array.isArray(options.stopId) ? options.stopId[0] : options.stopId
const rawExhibitId = Array.isArray(options.id) ? options.id[0] : options.id
// New detail links are keyed by stopId. id remains optional so bookmarked
// historical routes and surrounding page metadata can still be preserved.
const stopId = rawStopId ? String(rawStopId) : String(rawExhibitId || '')
const exhibitId = rawExhibitId ? String(rawExhibitId) : stopId
if (!stopId) {
detailState.value = 'missing'
return
}
const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM'
? 'ITEM'
: rawTargetType === 'STOP' || rawTargetType === 'GUIDE_STOP'
? 'STOP'
: undefined
const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId
const targetId = rawTargetType === 'GUIDE_STOP'
? String(exhibitId)
: rawTargetId ? String(rawTargetId) : undefined
const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
const rawAutoplay = Array.isArray(options.autoplay) ? options.autoplay[0] : options.autoplay
shouldAutoplayOnEntry.value = rawAutoplay === '1' || rawAutoplay === 'true'
const lang = typeof rawLang === 'string'
? normalizeGuideAudioLanguage(rawLang)
: 'zh-CN'
@@ -562,14 +541,25 @@ onLoad(async (options: any = {}) => {
try {
detailEntryRequest.value = {
exhibitId,
targetType,
targetId,
stopId,
hallId: Array.isArray(options.hallId) ? options.hallId[0] : options.hallId,
hallName: Array.isArray(options.hallName) ? options.hallName[0] : options.hallName,
floorId: Array.isArray(options.floorId) ? options.floorId[0] : options.floorId,
poiId: Array.isArray(options.poiId) ? options.poiId[0] : options.poiId
}
await loadExplainDetail(detailEntryRequest.value, lang)
if (shouldAutoplayOnEntry.value) {
shouldAutoplayOnEntry.value = false
try {
await handlePlayAudio()
} catch (error) {
console.warn('自动播放讲解失败,将等待用户手动播放:', error)
uni.showToast({
title: '点击底部播放键开始讲解',
icon: 'none'
})
}
}
} catch (error) {
console.error('讲解详情加载失败:', error)
detailState.value = 'error'
@@ -684,17 +674,27 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
applyDetailText(lang)
return
}
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
if (requestSequence !== languageSwitchSequence) return
heroImageIndex.value = 0
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
exhibit.value = nextViewModel
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
const audioOption = resolveGuideAudioOption(
activeDetailAudioOptions.value,
lang,
selectedAudioGender.value
)
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
language: lang,
url: audioOption?.playUrl,
duration: audioOption?.duration,
status: audioOption?.playUrl ? 'playable' : 'unavailable',
unavailableReason: audioOption?.playUrl ? undefined : '当前语言暂无语音讲解',
hasText: Boolean(exhibit.value.audio.textVariants?.some((variant) => (
variant.version === detailAudioVersion.value && (variant.languageCode === lang || (lang === 'yue-HK' && variant.languageCode === 'zh-CN'))
)))
}
}
void loadFullDetailTextForLanguage(request, lang, exhibit.value)
if (shouldContinueAudio) {
// The player must become actionable before resuming with the new language track.
languageSwitchLoading.value = false
@@ -742,13 +742,11 @@ watch(
() => globalAudioPlayer.currentSource.value,
(source) => {
const lang = source?.lang
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
const stopId = exhibit.value.audio.stopId || exhibit.value.id
if (
isAudioLanguage(lang)
&& source?.targetType === targetType
&& source.targetId === targetId
&& (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId))
) {
void handleLanguageChange(lang, { syncAudio: false })
}
@@ -761,12 +759,7 @@ function buildDetailRoute(lang: AudioLanguage = selectedAudioLanguage.value) {
id: request?.exhibitId || exhibit.value.id,
tab: activeTopTab.value
})
if (currentAudioTarget.value.targetType) {
params.set('targetType', currentAudioTarget.value.targetType)
}
if (currentAudioTarget.value.targetId) {
params.set('targetId', currentAudioTarget.value.targetId)
}
params.set('stopId', request?.stopId || exhibit.value.audio.stopId || exhibit.value.id)
if (lang) {
params.set('lang', lang)
}
@@ -782,7 +775,7 @@ const toAudioItem = (
return {
id: media.id,
name: selection.playInfo?.title || selection.exhibit.name,
name: selection.exhibit.name,
audioUrl,
image: heroImage.value,
duration: media.duration,
@@ -806,11 +799,10 @@ const refreshCurrentAudioOnce = async (message: string) => {
audioDuration: exhibit.value.audio.duration,
audioStatus: 'READY',
audioLanguage: selectedAudioLanguage.value,
audioOptions: exhibit.value.audio.audioOptions,
audioOptions: activeDetailAudioOptions.value,
guideVersion: detailAudioVersion.value,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
}, {
refreshPlayInfo: true
})
retryingAudio.value = false
@@ -833,11 +825,11 @@ const playDetailAudio = async (audio: AudioItem) => {
await globalAudioPlayer.play(audio, {
source: {
exhibitId: exhibit.value.id,
targetType: currentAudioTarget.value.targetType,
targetId: currentAudioTarget.value.targetId,
stopId: currentAudioTarget.value.stopId,
lang: selectedAudioLanguage.value,
channelCode: selectedDetailAudioOption.value?.channelCode,
voiceGender: currentDetailAudioGender.value,
audioOptions: activeDetailAudioOptions.value,
title: audio.name,
detailRoute: buildDetailRoute()
},
@@ -883,7 +875,8 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
audioLanguage: selectedAudioLanguage.value,
audioUnavailableReason: exhibit.value.audio.unavailableReason,
audioOptions: exhibit.value.audio.audioOptions,
audioOptions: activeDetailAudioOptions.value,
guideVersion: detailAudioVersion.value,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
}, {
@@ -945,12 +938,31 @@ const fallbackToExplainObjectList = () => {
})
}
type ExplainDetailPageStackEntry = {
route?: string
}
const returnToExplainObjectList = () => {
closeDetailAudioOnExit()
// H5 history can omit intermediate uni-app pages after a refresh or deep link.
if (typeof window !== 'undefined' && window.location.hash.startsWith('#/')) {
fallbackToExplainObjectList()
return
}
const pages = getCurrentPages()
const previousPage = pages[pages.length - 2] as ExplainDetailPageStackEntry | undefined
if (previousPage?.route === 'pages/explain/guide-stop-list') {
uni.navigateBack({
delta: 1,
fail: fallbackToExplainObjectList
})
return
}
fallbackToExplainObjectList()
}
const handleBack = returnToExplainObjectList

View File

@@ -25,7 +25,6 @@ import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue'
import { explainUseCase } from '@/usecases/explainUseCase'
import type { ExplainGuideStop } from '@/domain/museum'
import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget'
import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs'
const PAGE_SIZE = 20
@@ -53,11 +52,8 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogIte
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio,
hasTextRecord: stop.hasTextRecord,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel,
playTargetType: stop.playTargetType,
playTargetId: stop.playTargetId
guideLevel: stop.guideLevel
}))
const syncPageTitle = (title: string) => {
@@ -130,10 +126,8 @@ onUnmounted(() => {
})
const handleExplainGuideStopClick = (stop: ExplainGuideStopCatalogItem) => {
const target = stop.playTargetType && stop.playTargetId
? { targetType: stop.playTargetType, targetId: stop.playTargetId }
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id })
const params = new URLSearchParams({ id: stop.id, tab: 'explain', targetType: target.targetType, targetId: target.targetId })
const stopId = stop.id
const params = new URLSearchParams({ id: stopId, stopId, tab: 'explain' })
// 分页对象可能未回填展厅字段,详情返回必须保留当前列表的展厅上下文。
const hallId = stop.hallId || selectedExplainHallId.value
const hallName = stop.hallName || selectedExplainHallName.value

View File

@@ -8,11 +8,14 @@
>
<view class="explain-all-page">
<ExplainHallSelect
ref="hallSelectRef"
:halls="explainHallItems"
stage="hall"
:loading="explainLoading"
:error="explainError"
:code-submitting="codeSubmitting"
@hall-click="handleExplainHallClick"
@code-submit="handleExplainCodeSubmit"
@retry="loadExplainHalls"
@back="handleBack"
/>
@@ -41,12 +44,15 @@ import {
} from '@/utils/guideTopTabs'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
import { explainCodeDetailUrl, explainCodeErrorMessage } from '@/utils/explainCodeEntry'
import { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService'
import { syncHostNavigationTitle } from '@/utils/hostNavigationTitle'
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainLoading = ref(false)
const explainError = ref('')
const hallSelectRef = ref<InstanceType<typeof ExplainHallSelect> | null>(null)
const codeSubmitting = ref(false)
let explainLoadPromise: Promise<void> | null = null
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
@@ -270,6 +276,20 @@ const handleExplainHallClick = (hallId: string) => {
})
}
const handleExplainCodeSubmit = async (code: string) => {
if (codeSubmitting.value) return
codeSubmitting.value = true
try {
const target = await explainUseCase.resolveExplainCode(code)
hallSelectRef.value?.completeCodeEntry()
uni.navigateTo({ url: explainCodeDetailUrl(target) })
} catch (error) {
hallSelectRef.value?.showCodeEntryError(explainCodeErrorMessage(error))
} finally {
codeSubmitting.value = false
}
}
</script>
<style scoped lang="scss">

View File

@@ -209,11 +209,7 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
id: exhibit.id,
tab: activeTopTab.value
})
if (exhibit.playTargetType && exhibit.playTargetId) {
params.set('targetType', exhibit.playTargetType)
params.set('targetId', exhibit.playTargetId)
}
params.set('stopId', exhibit.playTargetId || exhibit.id)
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`

View File

@@ -247,11 +247,14 @@
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainHallSelect
ref="explainHallSelectRef"
:halls="explainHallItems"
stage="hall"
:loading="explainLoading"
:error="explainError"
:code-submitting="codeSubmitting"
@hall-click="handleExplainHallClick"
@code-submit="handleExplainCodeSubmit"
@retry="loadExplainHalls"
@back="handleExplainBack"
/>
@@ -308,6 +311,7 @@ import ExplainHallSelect, {
type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
import { explainCodeDetailUrl, explainCodeErrorMessage } from '@/utils/explainCodeEntry'
import {
getLastGuideLocationPreviewUrl,
isGuideTopTab,
@@ -1013,6 +1017,8 @@ const selectedGuidePoiActions = computed(() => {
})
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainHallSelectRef = ref<InstanceType<typeof ExplainHallSelect> | null>(null)
const codeSubmitting = ref(false)
const explainLoading = ref(false)
const explainError = ref('')
let explainLoadPromise: Promise<void> | null = null
@@ -2574,6 +2580,20 @@ const handleExplainHallClick = async (hallId: string) => {
})
}
const handleExplainCodeSubmit = async (code: string) => {
if (codeSubmitting.value) return
codeSubmitting.value = true
try {
const target = await explainUseCase.resolveExplainCode(code)
explainHallSelectRef.value?.completeCodeEntry()
uni.navigateTo({ url: explainCodeDetailUrl(target) })
} catch (error) {
explainHallSelectRef.value?.showCodeEntryError(explainCodeErrorMessage(error))
} finally {
codeSubmitting.value = false
}
}
const handleExplainBack = () => {
currentTab.value = 'guide'
syncTopTabToUrl('guide')

View File

@@ -1,40 +1,32 @@
import { dataSourceConfig } from '@/config/dataSource'
import {
dataSourceConfig
} from '@/config/dataSource'
import {
type AudioPlayTargetType
} from '@/domain/museum'
import {
toGuideAudioPlayInfo,
toGuideAudioTextInfo,
toGuideStopDetail,
toGuideStopInfo,
type BackendAudioPlayInfo,
type BackendAudioTextInfo,
type BackendGuideStopDetail,
type BackendGuideStopInfo,
type GuideAudioPlayInfo,
type GuideAudioTextInfo,
type GuideAudioLanguage,
type GuideAudioVersion,
type GuideStopDetail,
type GuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter'
export type AudioLanguage = GuideAudioLanguage
export interface AudioPlayInfoRequest {
targetType: AudioPlayTargetType
targetId: string
lang?: AudioLanguage
export interface GetStopDetailOptions {
version?: GuideAudioVersion
refresh?: boolean
}
export interface GuideStopInfoRequest {
targetType: AudioPlayTargetType
targetId: string
lang?: AudioLanguage
/**
* H5 detail pages only need the active language's text availability. Keep the
* legacy all-language payload available for older consumers that opt out.
*/
lightweight?: boolean
export interface AudioPlayInfoRepository {
getStopDetail(stopId: string, options?: GetStopDetailOptions): Promise<GuideStopDetail>
getStopInfoByCode(code: string, lang?: AudioLanguage): Promise<GuideStopInfo>
clearCache(stopId?: string): void
}
interface GuideStopDetailResponse {
code: number
msg?: string
data?: BackendGuideStopDetail
}
interface GuideStopInfoResponse {
@@ -43,34 +35,26 @@ interface GuideStopInfoResponse {
data?: BackendGuideStopInfo
}
export interface AudioPlayInfo extends GuideAudioPlayInfo {}
interface AudioPlayInfoResponse {
code: number
msg?: string
data?: BackendAudioPlayInfo
export class GuideApiError extends Error {
constructor(message: string, readonly code?: number) {
super(message)
this.name = 'GuideApiError'
}
}
export interface AudioTextInfoRequest {
targetType: AudioPlayTargetType
targetId: string
lang?: AudioLanguage
interface CacheEntry<T> {
value: T
expiresAt: number
}
export interface AudioTextInfo extends GuideAudioTextInfo {}
const STOP_DETAIL_TTL_MS = 10 * 60_000
const CACHE_MAX_ENTRIES = 80
interface AudioTextInfoResponse {
code: number
msg?: string
data?: BackendAudioTextInfo
}
const normalizeDetailVersion = (version?: GuideAudioVersion): GuideAudioVersion => (
version === 'extended' ? 'extended' : 'standard'
)
export interface AudioPlayInfoRepository {
getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo>
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
clearCache(lang?: AudioLanguage): void
}
const detailCacheKey = (stopId: string, version: GuideAudioVersion) => `${stopId}:version:${version}`
const reasonMessageMap: Record<string, string> = {
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
@@ -78,19 +62,30 @@ const reasonMessageMap: Record<string, string> = {
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
NO_TEXT: '当前语言暂无讲解词',
UNSUPPORTED_LANGUAGE: '不支持该语言',
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
GUIDE_STOP_NOT_EXISTS: '该讲解点不存在或暂不可展示',
SERVICE_ERROR: '语音服务暂不可用,请稍后重试'
}
const parseJsonPayload = <T>(payload: unknown): T => {
if (typeof payload === 'string') {
return JSON.parse(payload) as T
}
export const audioReasonToText = (reason?: string | null) => (
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
)
const parseJsonPayload = <T>(payload: unknown): T => {
if (typeof payload === 'string') return JSON.parse(payload) as T
return payload as T
}
const normalizeBaseUrl = (baseUrl: string) => {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) return trimmed
return `/${trimmed}`
}
const resolveAppApiBaseUrl = () => {
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl)
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
}
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
uni.request({
url,
@@ -99,10 +94,9 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
success: (response) => {
const statusCode = Number(response.statusCode || 0)
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`语音播放解析接口请求失败: ${statusCode}`))
reject(new Error(`讲解点详情请求失败: ${statusCode}`))
return
}
try {
resolve(parseJsonPayload<T>(response.data))
} catch (error) {
@@ -113,245 +107,98 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
})
})
const unavailableAudioApiEndpoints = new Set<'stopInfo' | 'playInfo' | 'textInfo'>()
const normalizeBaseUrl = (baseUrl: string) => {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) {
return trimmed
}
return `/${trimmed}`
}
const resolveAudioApiBaseUrl = () => {
// Catalog APIs use apiBaseUrl in BackendExplainContentProvider; stop/detail/play/text APIs use audioApiBaseUrl.
// They are both /app-api today, but audioApiBaseUrl can be split to a dedicated proxy or host later.
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl)
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
}
type RequiredAudioPlayInfoRequest = Required<Omit<AudioPlayInfoRequest, 'refresh'>>
type RequiredGuideStopInfoRequest = Required<GuideStopInfoRequest>
type RequiredAudioTextInfoRequest = Required<AudioTextInfoRequest>
const audioKey = ({ targetType, targetId, lang }: RequiredAudioPlayInfoRequest) => (
`${targetType}:${targetId}:${lang}`
)
const stopInfoKey = ({ targetType, targetId, lang, lightweight }: RequiredGuideStopInfoRequest) => (
`${targetType}:${targetId}:${lang}:${lightweight ? 'lightweight' : 'full'}`
)
const audioTextKey = ({ targetType, targetId, lang }: RequiredAudioTextInfoRequest) => (
`${targetType}:${targetId}:${lang}`
)
const STOP_INFO_TTL_MS = 10 * 60_000
const PLAY_INFO_TTL_MS = 10 * 60_000
const TEXT_INFO_TTL_MS = 10 * 60_000
const CACHE_MAX_ENTRIES = 80
const EXPIRES_AT_SAFETY_MS = 30_000
interface CacheEntry<T> {
value: T
expiresAt: number
}
const isMissingRouteResponse = (response: { code: number; msg?: string }) => (
response.code === 404 && /请求地址不存在/.test(response.msg || '')
)
const markGuideAudioApiUnavailable = (
endpoint: 'stopInfo' | 'playInfo' | 'textInfo',
response: { code: number; msg?: string }
) => {
if (isMissingRouteResponse(response)) {
unavailableAudioApiEndpoints.add(endpoint)
}
}
export const audioReasonToText = (reason?: string | null) => (
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
)
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
private readonly stopInfoCache = new Map<string, CacheEntry<GuideStopInfo>>()
private readonly cache = new Map<string, CacheEntry<AudioPlayInfo>>()
private readonly textCache = new Map<string, CacheEntry<AudioTextInfo>>()
private readonly stopInfoInflight = new Map<string, Promise<GuideStopInfo>>()
private readonly playInfoInflight = new Map<string, Promise<AudioPlayInfo>>()
private readonly textInfoInflight = new Map<string, Promise<AudioTextInfo>>()
private readonly detailCache = new Map<string, CacheEntry<GuideStopDetail>>()
private readonly detailInflight = new Map<string, Promise<GuideStopDetail>>()
private epoch = 0
private getCached<T>(cache: Map<string, CacheEntry<T>>, key: string): T | null {
const entry = cache.get(key)
private getCached(stopId: string, version: GuideAudioVersion) {
const key = detailCacheKey(stopId, version)
const entry = this.detailCache.get(key)
if (!entry) return null
if (entry.expiresAt <= Date.now()) {
cache.delete(key)
this.detailCache.delete(key)
return null
}
cache.delete(key)
cache.set(key, entry)
this.detailCache.delete(key)
this.detailCache.set(key, entry)
return entry.value
}
private setCached<T>(cache: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number) {
cache.delete(key)
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
while (cache.size > CACHE_MAX_ENTRIES) {
const oldestKey = cache.keys().next().value
private setCached(stopId: string, version: GuideAudioVersion, value: GuideStopDetail) {
const key = detailCacheKey(stopId, version)
this.detailCache.delete(key)
this.detailCache.set(key, { value, expiresAt: Date.now() + STOP_DETAIL_TTL_MS })
while (this.detailCache.size > CACHE_MAX_ENTRIES) {
const oldestKey = this.detailCache.keys().next().value
if (!oldestKey) break
cache.delete(oldestKey)
this.detailCache.delete(oldestKey)
}
}
private async coalesce<T>(
inflight: Map<string, Promise<T>>,
key: string,
loader: () => Promise<T>
): Promise<T> {
const existing = inflight.get(key)
async getStopInfoByCode(code: string, lang: AudioLanguage = dataSourceConfig.audioLanguage): Promise<GuideStopInfo> {
const normalizedCode = String(code || '').trim()
if (!normalizedCode) throw new GuideApiError('讲解编号不能为空', 1020007000)
const params = new URLSearchParams({ code: normalizedCode, lang })
const response = await requestJson<GuideStopInfoResponse>(
`${resolveAppApiBaseUrl()}/gis/guide/stop/info-by-code?${params.toString()}`
)
if (response.code !== 0 || !response.data) {
throw new GuideApiError(response.msg || '编号讲解查询失败', response.code)
}
return toGuideStopInfo(response.data, {
targetType: 'STOP',
targetId: '',
lang
})
}
async getStopDetail(stopId: string, options: GetStopDetailOptions = {}): Promise<GuideStopDetail> {
const normalizedStopId = String(stopId || '').trim()
if (!normalizedStopId) throw new Error('讲解点 ID 不能为空')
const version = normalizeDetailVersion(options.version)
const key = detailCacheKey(normalizedStopId, version)
const cached = this.getCached(normalizedStopId, version)
if (!options.refresh && cached) return cached
const existing = this.detailInflight.get(key)
if (existing) return existing
const promise = loader()
inflight.set(key, promise)
const epoch = this.epoch
const params = new URLSearchParams({ version })
const request = requestJson<GuideStopDetailResponse>(
`${resolveAppApiBaseUrl()}/gis/guide/stops/${encodeURIComponent(normalizedStopId)}?${params.toString()}`
).then((response) => {
if (response.code !== 0 || !response.data) {
throw new GuideApiError(response.msg || '讲解点详情加载失败', response.code)
}
const detail = toGuideStopDetail(response.data)
if (epoch === this.epoch) this.setCached(normalizedStopId, version, detail)
return detail
})
this.detailInflight.set(key, request)
try {
return await promise
return await request
} finally {
if (inflight.get(key) === promise) inflight.delete(key)
if (this.detailInflight.get(key) === request) {
this.detailInflight.delete(key)
}
}
}
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
if (unavailableAudioApiEndpoints.has('stopInfo')) {
throw new Error('讲解展示信息接口暂不可用')
}
const normalizedRequest: RequiredGuideStopInfoRequest = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage),
lightweight: request.lightweight !== false
}
const key = stopInfoKey(normalizedRequest)
const cached = this.getCached(this.stopInfoCache, key)
if (cached) return cached
return this.coalesce(this.stopInfoInflight, key, async () => {
const epoch = this.epoch
const params = new URLSearchParams({
targetType: normalizedRequest.targetType,
targetId: normalizedRequest.targetId,
lang: normalizedRequest.lang,
lightweight: String(normalizedRequest.lightweight)
})
const response = await requestJson<GuideStopInfoResponse>(`${resolveAudioApiBaseUrl()}/gis/guide/stop/info?${params.toString()}`)
if (response.code !== 0 || !response.data) {
markGuideAudioApiUnavailable('stopInfo', response)
throw new Error(response.msg || '讲解点展示信息加载失败')
}
const stopInfo = toGuideStopInfo(response.data, normalizedRequest)
if (epoch === this.epoch) this.setCached(this.stopInfoCache, key, stopInfo, STOP_INFO_TTL_MS)
return stopInfo
})
}
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
const normalizedRequest: RequiredAudioPlayInfoRequest = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
}
const key = audioKey(normalizedRequest)
if (unavailableAudioApiEndpoints.has('playInfo')) {
return {
playable: false,
targetType: normalizedRequest.targetType,
targetId: normalizedRequest.targetId,
lang: normalizedRequest.lang,
hasText: false,
fallback: false,
reason: 'SERVICE_ERROR'
}
}
const cached = this.getCached(this.cache, key)
if (!request.refresh && cached) return cached
// Force refresh bypasses completed cache, while the same concurrent refresh still shares this Promise.
return this.coalesce(this.playInfoInflight, key, async () => {
const epoch = this.epoch
const params = new URLSearchParams(normalizedRequest)
const response = await requestJson<AudioPlayInfoResponse>(`${resolveAudioApiBaseUrl()}/gis/guide/audio/play-info?${params.toString()}`)
if (response.code !== 0 || !response.data) {
markGuideAudioApiUnavailable('playInfo', response)
throw new Error(response.msg || '语音播放解析失败')
}
const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest)
const serverExpiry = playInfo.expiresAt ? new Date(playInfo.expiresAt).getTime() - EXPIRES_AT_SAFETY_MS : Infinity
const ttlMs = Math.max(0, Math.min(PLAY_INFO_TTL_MS, serverExpiry - Date.now()))
if (epoch === this.epoch && playInfo.playable && ttlMs > 0) this.setCached(this.cache, key, playInfo, ttlMs)
return playInfo
})
}
async getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo> {
const normalizedRequest: RequiredAudioTextInfoRequest = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
}
const key = audioTextKey(normalizedRequest)
if (unavailableAudioApiEndpoints.has('textInfo')) {
return {
available: false,
targetType: normalizedRequest.targetType,
targetId: normalizedRequest.targetId,
lang: normalizedRequest.lang,
reason: 'SERVICE_ERROR'
}
}
const cached = this.getCached(this.textCache, key)
if (cached) return cached
return this.coalesce(this.textInfoInflight, key, async () => {
const epoch = this.epoch
const params = new URLSearchParams(normalizedRequest)
const response = await requestJson<AudioTextInfoResponse>(`${resolveAudioApiBaseUrl()}/gis/guide/audio/text-info?${params.toString()}`)
if (response.code !== 0 || !response.data) {
markGuideAudioApiUnavailable('textInfo', response)
throw new Error(response.msg || '讲解词正文解析失败')
}
const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest)
if (epoch === this.epoch && textInfo.available) this.setCached(this.textCache, key, textInfo, TEXT_INFO_TTL_MS)
return textInfo
})
}
clearCache(lang?: AudioLanguage) {
clearCache(stopId?: string) {
this.epoch += 1
if (!lang) {
this.stopInfoCache.clear()
this.cache.clear()
this.textCache.clear()
unavailableAudioApiEndpoints.clear()
if (stopId?.trim()) {
const normalizedStopId = stopId.trim()
this.detailCache.delete(detailCacheKey(normalizedStopId, 'standard'))
this.detailCache.delete(detailCacheKey(normalizedStopId, 'extended'))
return
}
Array.from(this.stopInfoCache.keys()).forEach((key) => {
if (key.endsWith(`:${lang}`)) {
this.stopInfoCache.delete(key)
}
})
Array.from(this.cache.keys()).forEach((key) => {
if (key.endsWith(`:${lang}`)) {
this.cache.delete(key)
}
})
Array.from(this.textCache.keys()).forEach((key) => {
if (key.endsWith(`:${lang}`)) {
this.textCache.delete(key)
}
})
unavailableAudioApiEndpoints.clear()
this.detailCache.clear()
}
}

View File

@@ -1,10 +1,17 @@
import type {
AudioLanguage,
AudioPlayInfoRequest
AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import type { AudioPlayTargetType } from '@/domain/museum'
/** Historical lookup-only shape. It is not a runtime API request contract. */
export interface PublishedExhibitAudioTarget {
targetType: AudioPlayTargetType
targetId: string
lang?: AudioLanguage
}
export interface PublishedExhibitAudioResolution {
targets: AudioPlayInfoRequest[]
targets: PublishedExhibitAudioTarget[]
}
export interface PublishedExhibitAudioRepository {

View File

@@ -18,20 +18,15 @@ import {
audioPlayInfoRepository,
audioReasonToText,
type AudioLanguage,
type AudioPlayInfo,
type AudioPlayInfoRepository,
type GuideStopInfoRequest,
type AudioTextInfo
type AudioPlayInfoRepository
} from '@/repositories/AudioPlayInfoRepository'
import type {
GuideStopInfo
GuideAudioVersion,
GuideStopDetail
} from '@/data/adapters/guideStopInfoAdapter'
import {
dataSourceConfig
} from '@/config/dataSource'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import {
resolveGuideAudioLanguages,
resolveGuideAudioOption
@@ -43,14 +38,15 @@ export interface ExplainAudioSelection {
media: MediaAsset | null
playable: boolean
unavailableMessage?: string
playInfo?: AudioPlayInfo
}
export interface ExplainDetailEntryRequest {
exhibitId: string
stopId?: string
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage
version?: GuideAudioVersion
// Navigation context is non-authoritative and keeps deep links independent of catalog loading.
hallId?: string
hallName?: string
@@ -61,7 +57,13 @@ export interface ExplainDetailEntryRequest {
export interface ExplainTextSelection {
exhibit: MuseumExhibit
textInfo: AudioTextInfo
textInfo: {
available: boolean
lang: AudioLanguage
text?: string
textLength?: number
textHash?: string
}
available: boolean
unavailableMessage?: string
}
@@ -76,6 +78,11 @@ export interface ExplainHallSummary {
guideStopCount: number
}
export interface ExplainCodeResolution {
code: string
stopId: string
}
export class ExplainUseCase {
constructor(
private readonly explain: ExplainRepository = explainRepository,
@@ -96,54 +103,17 @@ export class ExplainUseCase {
}
}
private resolveAudioTarget(exhibit: MuseumExhibit, track?: ExplainTrack | null) {
const targetType: AudioPlayTargetType = track?.playTargetType
|| exhibit.playTargetType
|| 'ITEM'
const targetId = track?.playTargetId
|| exhibit.playTargetId
|| exhibit.id
return { targetType, targetId }
}
private resolveStopInfoAudioTarget(stopInfo: GuideStopInfo) {
return {
targetType: stopInfo.playTargetType || stopInfo.targetType,
targetId: stopInfo.playTargetId || stopInfo.targetId
}
}
private async resolveDetailEntryTarget(request: ExplainDetailEntryRequest): Promise<Required<GuideStopInfoRequest>> {
const lang = request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
if (request.targetType && request.targetId) {
return {
targetType: request.targetType,
targetId: request.targetId,
lang,
lightweight: true
}
}
return {
targetType: 'ITEM',
targetId: request.exhibitId,
lang,
lightweight: true
}
private resolveDetailStopId(request: ExplainDetailEntryRequest) {
return String(request.stopId || request.targetId || request.exhibitId || '').trim()
}
private async resolveStaticDetailFallback(
request: ExplainDetailEntryRequest,
entryTarget: Required<GuideStopInfoRequest>
entryTarget: { stopId: string }
): Promise<MuseumExhibit | null> {
if (entryTarget.targetType !== 'ITEM' && entryTarget.targetType !== 'STOP') {
return null
}
const candidateIds = Array.from(new Set([
request.exhibitId,
entryTarget.targetId
entryTarget.stopId
].map((id) => id?.trim()).filter(Boolean)))
for (const candidateId of candidateIds) {
@@ -156,201 +126,89 @@ export class ExplainUseCase {
.catch(() => null)
}
private languageLabel(language: AudioLanguage) {
if (language === 'en-US') return '英文'
if (language === 'yue-HK') return '粤语'
return '中文'
private selectTextVariant(detail: GuideStopDetail, language: AudioLanguage, version = detail.version) {
const exact = detail.textVariants.find((variant) => (
variant.version === version && variant.languageCode === language
))
if (exact) return exact
if (language === 'yue-HK') {
return detail.textVariants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN')
}
return detail.textVariants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN')
}
private applyLanguageVariant(exhibit: MuseumExhibit, language: AudioLanguage): MuseumExhibit {
const variant = exhibit.audioVariants?.[language]
const mandarinText = exhibit.audioVariants?.['zh-CN']?.text
|| exhibit.guideText
|| exhibit.description
const supportedLanguages = exhibit.supportedLanguages?.length
? exhibit.supportedLanguages
: exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language]
if (!variant) {
const label = this.languageLabel(language)
const guideText = language === 'yue-HK'
? mandarinText || '当前讲解词暂未配置。'
: `${label}讲解词暂未配置。`
return {
...exhibit,
guideText,
audioUrl: undefined,
audioDuration: undefined,
audioLanguage: language,
audioHasText: language === 'yue-HK' && Boolean(mandarinText),
audioAvailable: false,
audioStatus: 'MISSING',
audioUnavailableReason: `当前讲解暂无${label}音频`,
supportedLanguages
}
}
return {
...exhibit,
guideTitle: variant.title || exhibit.guideTitle,
guideText: language === 'yue-HK'
? mandarinText || '当前讲解词暂未配置。'
: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`,
audioUrl: variant.audioUrl,
audioDuration: variant.audioDuration,
audioLanguage: language,
audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true,
audioAvailable: variant.available === true && Boolean(variant.audioUrl),
audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING',
audioUnavailableReason: variant.available && variant.audioUrl
? undefined
: `当前讲解暂无${this.languageLabel(language)}音频`,
supportedLanguages
}
}
private toExhibitFromStopInfo(
stopInfo: GuideStopInfo,
private toExhibitFromStopDetail(
detail: GuideStopDetail,
fallback?: MuseumExhibit | null,
navigationContext?: ExplainDetailEntryRequest
navigationContext?: ExplainDetailEntryRequest,
language: AudioLanguage = 'zh-CN',
gender: GuideAudioGender = 'female'
): MuseumExhibit {
const linkedPrimary = stopInfo.linkedExhibits[0]
const coverImage = stopInfo.imageStatus === 'READY'
? stopInfo.coverImageUrl
: undefined
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
// Product defaults Chinese and English to male even if the management default is female.
const audioOption = resolveGuideAudioOption(stopInfo.audioOptions, stopInfo.lang, 'male')
const audioAvailable = Boolean(audioOption?.playUrl) || stopInfo.audioStatus === 'READY'
const linkedPrimary = detail.linkedExhibits[0]
const textVariant = this.selectTextVariant(detail, language)
const currentVersionTracks = detail.audioTracks.filter((track) => track.version === detail.version)
const recommendedTrack = currentVersionTracks.find((track) => track.channelCode === detail.recommendedTrackCode)
const preferredGender = language === 'yue-HK'
? 'female'
: recommendedTrack?.languageCode === language ? recommendedTrack.gender : gender
const audioOption = resolveGuideAudioOption(currentVersionTracks, language, preferredGender)
const audioAvailable = Boolean(audioOption?.playUrl)
const supportedLanguages = resolveGuideAudioLanguages(
stopInfo.audioOptions,
stopInfo.supportedLanguages
currentVersionTracks,
currentVersionTracks.map((track) => track.languageCode)
)
return {
...(fallback || {}),
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
id: detail.id,
name: detail.name || fallback?.name || linkedPrimary?.name || '讲解内容',
hallId: fallback?.hallId || navigationContext?.hallId,
hallName: fallback?.hallName || navigationContext?.hallName,
floorId: stopInfo.floorId || fallback?.floorId || navigationContext?.floorId,
floorId: detail.floorId || fallback?.floorId || navigationContext?.floorId,
floorLabel: fallback?.floorLabel || navigationContext?.floorLabel,
image: coverImage,
description,
poiId: stopInfo.poiId || fallback?.poiId || navigationContext?.poiId,
sourcePoiId: stopInfo.poiId || fallback?.sourcePoiId,
mapX: stopInfo.mapX ?? fallback?.mapX,
mapY: stopInfo.mapY ?? fallback?.mapY,
location: fallback?.location,
year: fallback?.year,
material: fallback?.material,
size: fallback?.size,
tags: fallback?.tags,
guideTitle: stopInfo.title || fallback?.guideTitle,
guideText: stopInfo.description || fallback?.guideText || fallback?.description,
image: detail.imageStatus === 'READY' ? detail.coverImageUrl : undefined,
description: detail.description || fallback?.description || '该讲解暂无简介。',
poiId: detail.poiId || fallback?.poiId || navigationContext?.poiId,
sourcePoiId: detail.poiId || fallback?.sourcePoiId,
mapX: detail.mapX ?? fallback?.mapX,
mapY: detail.mapY ?? fallback?.mapY,
guideTitle: detail.name,
guideText: textVariant?.text || detail.description || fallback?.guideText || fallback?.description,
audioUrl: audioOption?.playUrl,
audioDuration: audioOption?.duration,
audioLanguage: stopInfo.lang,
audioHasText: stopInfo.hasText,
audioText: undefined,
audioTextLength: undefined,
audioTextHash: undefined,
audioNarrationTier: undefined,
audioUnavailableReason: audioAvailable
? undefined
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
audioLanguage: language,
audioHasText: Boolean(textVariant?.text),
audioText: textVariant?.text,
audioTextLength: textVariant?.textLength,
audioTextHash: textVariant?.textHash,
audioUnavailableReason: audioAvailable ? undefined : audioReasonToText(detail.hasAudio ? undefined : 'NO_PUBLISHED_AUDIO'),
audioAvailable,
audioStatus: audioAvailable ? 'READY' : stopInfo.audioStatus,
audioStatus: audioAvailable ? 'READY' : 'MISSING',
supportedLanguages,
audioOptions: stopInfo.audioOptions,
imageStatus: stopInfo.imageStatus,
imageSource: stopInfo.imageSource,
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
linkedExhibitCount: stopInfo.linkedExhibitCount,
isSharedStop: stopInfo.isSharedStop,
linkedExhibits: stopInfo.linkedExhibits,
stopInfoAvailable: stopInfo.available,
stopInfoReason: stopInfo.reason,
resolvedStopId: stopInfo.resolvedStopId,
playTargetType: audioTarget.targetType,
playTargetId: audioTarget.targetId
audioOptions: detail.audioTracks,
textVariants: detail.textVariants,
recommendedTrackCode: detail.recommendedTrackCode,
guideVersion: detail.version,
textVariantCount: detail.textVariantCount,
audioTrackCount: detail.audioTrackCount,
imageStatus: detail.imageStatus,
galleryUrls: detail.imageStatus === 'READY' ? detail.galleryUrls : [],
linkedExhibitCount: detail.linkedExhibitCount,
isSharedStop: detail.isSharedStop,
linkedExhibits: detail.linkedExhibits,
stopInfoAvailable: true,
resolvedStopId: detail.id,
playTargetType: 'STOP',
playTargetId: detail.id
}
}
private applyPlayInfo(
exhibit: MuseumExhibit,
playInfo: AudioPlayInfo
): MuseumExhibit {
const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl)
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : undefined
const nextAudioDuration = typeof playInfo.duration === 'number'
? playInfo.duration
: exhibit.audioDuration
return {
...exhibit,
guideTitle: playInfo.title || exhibit.guideTitle,
audioUrl: nextAudioUrl,
audioDuration: nextAudioDuration,
audioLanguage: playInfo.lang || exhibit.audioLanguage,
audioSubtitleUrl: playInfo.subtitleUrl || exhibit.audioSubtitleUrl,
audioHasText: playInfo.hasText === true || exhibit.audioHasText,
audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier,
audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason),
audioAvailable: apiPlayable,
audioStatus: apiPlayable ? 'READY' : 'MISSING'
}
}
private applyTextInfo(exhibit: MuseumExhibit, textInfo: AudioTextInfo | null): MuseumExhibit {
if (!textInfo?.available || !textInfo.text) return exhibit
return {
...exhibit,
guideTitle: textInfo.title || exhibit.guideTitle,
guideText: textInfo.text,
audioText: textInfo.text,
audioTextLength: textInfo.textLength || exhibit.audioTextLength,
audioTextHash: textInfo.textHash || exhibit.audioTextHash,
audioLanguage: textInfo.lang || exhibit.audioLanguage,
audioNarrationTier: textInfo.narrationTier || exhibit.audioNarrationTier,
audioHasText: true
}
}
private async enrichExhibitAudio(
exhibit: MuseumExhibit,
options: {
includeText?: boolean
track?: ExplainTrack | null
} = {}
): Promise<MuseumExhibit> {
const track = options.track ?? await this.explain.getTrackByExhibitId(exhibit.id).catch(() => null)
const baseExhibit = this.applyTrackAudioState(exhibit, track)
const { targetType, targetId } = this.resolveAudioTarget(baseExhibit, track)
private async enrichExhibitAudio(exhibit: MuseumExhibit): Promise<MuseumExhibit> {
if (exhibit.audioOptions?.length || !exhibit.id) return exhibit
try {
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo)
if (options.includeText && playInfo.hasText) {
try {
const textInfo = await this.audioPlayInfo.getTextInfo({
targetType,
targetId,
lang: playInfo.lang
})
nextExhibit = this.applyTextInfo(nextExhibit, textInfo)
} catch (error) {
console.warn('讲解词正文加载失败,将使用静态讲解文稿:', error)
}
}
return nextExhibit
} catch (error) {
console.warn('讲解播放信息加载失败,将使用静态讲解数据:', error)
return baseExhibit
const detail = await this.audioPlayInfo.getStopDetail(exhibit.resolvedStopId || exhibit.id, { version: 'standard' })
return this.toExhibitFromStopDetail(detail, exhibit)
} catch {
return exhibit
}
}
@@ -406,10 +264,7 @@ export class ExplainUseCase {
return baseExhibit
}
return this.enrichExhibitAudio(exhibit, {
includeText: options.includeText,
track
})
return this.enrichExhibitAudio(this.applyTrackAudioState(exhibit, track))
} catch (error) {
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
return exhibit
@@ -423,64 +278,40 @@ export class ExplainUseCase {
})
}
async resolveExplainCode(code: string, lang: AudioLanguage = dataSourceConfig.audioLanguage as AudioLanguage): Promise<ExplainCodeResolution> {
const stopInfo = await this.audioPlayInfo.getStopInfoByCode(code, lang)
const stopId = stopInfo.resolvedStopId || stopInfo.playTargetId || stopInfo.targetId
if (!stopInfo.available || !stopId) {
throw new Error('该编号的讲解内容暂不可用')
}
return { code: code.trim(), stopId }
}
async enterExplainDetail(request: ExplainDetailEntryRequest) {
const entryTarget = await this.resolveDetailEntryTarget(request)
// Remote stop-info is the authoritative detail source. Never make its first paint wait for catalog.
const stopId = this.resolveDetailStopId(request)
if (!stopId) throw new Error('讲解点 ID 不能为空')
const detail = await this.audioPlayInfo.getStopDetail(stopId, { version: request.version || 'standard' })
const language = request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
if (dataSourceConfig.explainContentMode === 'remote') {
const stopInfo = await this.audioPlayInfo.getStopInfo(entryTarget)
return this.toExhibitFromStopInfo(stopInfo, null, request)
return this.toExhibitFromStopDetail(detail, null, request, language)
}
const fallbackExhibit = await this.resolveStaticDetailFallback(request, entryTarget)
if (fallbackExhibit) {
return this.applyLanguageVariant(fallbackExhibit, entryTarget.lang)
}
const stopInfoResult = await this.audioPlayInfo.getStopInfo(entryTarget)
.then((stopInfo) => ({ stopInfo, error: null }))
.catch((error) => ({ stopInfo: null, error }))
if (stopInfoResult.stopInfo) {
return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit, request)
}
throw stopInfoResult.error || new Error('讲解详情加载失败')
const fallbackExhibit = await this.resolveStaticDetailFallback(request, { stopId })
return this.toExhibitFromStopDetail(detail, fallbackExhibit, request, language)
}
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
const targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id
const lang = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
try {
const textInfo = await this.audioPlayInfo.getTextInfo({
targetType,
targetId,
lang
})
const nextExhibit = this.applyTextInfo(exhibit, textInfo)
const variants = exhibit.textVariants || []
const version = exhibit.guideVersion || 'standard'
const variant = variants.find((item) => item.version === version && item.languageCode === lang)
|| (lang === 'yue-HK' ? variants.find((item) => item.version === version && item.languageCode === 'zh-CN') : undefined)
const text = variant?.text || (lang === 'yue-HK' ? exhibit.textVariants?.find((item) => item.languageCode === 'zh-CN')?.text : undefined)
const nextExhibit = text ? { ...exhibit, guideText: text, audioText: text, audioHasText: true } : exhibit
return {
exhibit: nextExhibit,
textInfo,
available: textInfo.available === true && Boolean(textInfo.text),
unavailableMessage: textInfo.available ? undefined : audioReasonToText(textInfo.reason || 'NO_TEXT')
}
} catch (error) {
console.error('讲解词正文解析失败:', error)
return {
exhibit,
textInfo: {
available: false,
targetType,
targetId,
lang,
reason: 'SERVICE_ERROR'
},
available: false,
unavailableMessage: '讲解词服务暂不可用,请稍后重试'
}
textInfo: { available: Boolean(text), lang, text, textLength: variant?.textLength, textHash: variant?.textHash },
available: Boolean(text),
unavailableMessage: text ? undefined : audioReasonToText('NO_TEXT')
}
}
@@ -554,11 +385,9 @@ export class ExplainUseCase {
}
openGuideStopDetail(stopId: string) {
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
return this.enterExplainDetail({
exhibitId: target.targetId,
targetType: target.targetType,
targetId: target.targetId
exhibitId: stopId,
stopId
})
}
@@ -578,86 +407,40 @@ export class ExplainUseCase {
const exhibit = detailExhibit || summaryExhibit
if (!exhibit) return null
const targetType: AudioPlayTargetType = track?.playTargetType
|| exhibit.playTargetType
|| 'ITEM'
const targetId = track?.playTargetId
|| exhibit.playTargetId
|| exhibit.id
const toPlayableSelection = (
playInfo: AudioPlayInfo,
requestTargetType: AudioPlayTargetType,
requestTargetId: string
): ExplainAudioSelection => {
const media: MediaAsset = {
id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`,
const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
const option = resolveGuideAudioOption(exhibit.audioOptions, language, 'female')
if (!option?.playUrl) {
return { exhibit, track, media: null, playable: false, unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO') }
}
return {
exhibit,
track,
media: {
id: `channel-${option.channelCode}`,
type: 'audio',
url: playInfo.playUrl || undefined,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
language: playInfo.lang,
url: option.playUrl,
duration: option.duration,
language,
available: true
}
return {
exhibit,
track,
media,
playable: true,
playInfo
}
}
try {
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
if (initialPlayInfo.playable && initialPlayInfo.playUrl) {
return toPlayableSelection(initialPlayInfo, targetType, targetId)
}
return {
exhibit,
track,
media: null,
playable: false,
playInfo: initialPlayInfo,
unavailableMessage: audioReasonToText(initialPlayInfo.reason)
}
} catch (error) {
console.error('语音播放解析失败:', error)
const serviceErrorPlayInfo: AudioPlayInfo = {
playable: false,
targetType,
targetId,
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
hasText: false,
fallback: false,
reason: 'SERVICE_ERROR'
}
return {
exhibit,
track,
media: null,
playable: false,
playInfo: serviceErrorPlayInfo,
unavailableMessage: '语音服务暂不可用,当前提供图文讲解'
}
},
playable: true
}
}
async selectAudioForExplainDetail(
exhibit: MuseumExhibit,
options: {
refreshPlayInfo?: boolean
voiceGender?: GuideAudioGender
} = {}
): Promise<ExplainAudioSelection> {
const targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id
const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
const audioOption = options.refreshPlayInfo
? undefined
: resolveGuideAudioOption(exhibit.audioOptions, language, options.voiceGender || 'male')
const version = exhibit.guideVersion || 'standard'
const optionsForVersion = (exhibit.audioOptions || []).filter((option) => !option.version || option.version === version)
const recommendedTrack = optionsForVersion.find((option) => option.channelCode === exhibit.recommendedTrackCode)
const preferredGender = language === 'yue-HK'
? 'female'
: options.voiceGender || (recommendedTrack?.languageCode === language ? recommendedTrack.gender : 'female')
const audioOption = resolveGuideAudioOption(optionsForVersion, language, preferredGender)
if (audioOption) {
const audioExhibit: MuseumExhibit = {
@@ -683,12 +466,12 @@ export class ExplainUseCase {
}
}
if (!options.refreshPlayInfo && exhibit.audioUrl?.trim()) {
if (exhibit.audioUrl?.trim()) {
return {
exhibit,
track: null,
media: {
id: `static-${targetType}-${targetId}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
id: `static-${exhibit.resolvedStopId || exhibit.id}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
type: 'audio',
url: exhibit.audioUrl,
duration: exhibit.audioDuration,
@@ -699,58 +482,12 @@ export class ExplainUseCase {
}
}
if (exhibit.audioStatus && exhibit.audioStatus !== 'READY') {
return {
exhibit,
track: null,
media: null,
playable: false,
unavailableMessage: audioReasonToText(exhibit.stopInfoReason || 'NO_PUBLISHED_AUDIO')
}
}
try {
const playInfo = await this.audioPlayInfo.getPlayInfo({
targetType,
targetId,
lang: language,
refresh: options.refreshPlayInfo === true
})
if (!playInfo.playable || !playInfo.playUrl) {
return {
exhibit,
track: null,
media: null,
playable: false,
playInfo,
unavailableMessage: audioReasonToText(playInfo.reason)
}
}
return {
exhibit,
track: null,
media: {
id: `play-${playInfo.audioId || `${targetType}-${targetId}`}`,
type: 'audio',
url: playInfo.playUrl,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
language: playInfo.lang,
available: true
},
playable: true,
playInfo
}
} catch (error) {
console.error('语音播放解析失败:', error)
return {
exhibit,
track: null,
media: null,
playable: false,
unavailableMessage: '语音服务暂不可用,请稍后重试'
}
unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO')
}
}
}

View File

@@ -33,6 +33,7 @@ export interface PoiDetailTarget {
detailId: string
hallId?: string
exhibitId?: string
stopId?: string
url: string
failureMessage: string
}
@@ -114,14 +115,24 @@ const resolveHallByControlledNameFallback = (
export class PoiDetailUseCase {
async resolve(poi: PoiDetailSourcePoi): Promise<PoiDetailTarget> {
if (poi.exhibitId) {
// POI search data identifies the exhibit, while the unified detail API only
// accepts a guide-stop ID. Resolve it from the catalog before navigation.
const exhibit = await explainUseCase.getExhibitById(poi.exhibitId).catch(() => null)
const stopId = exhibit?.resolvedStopId
const detailUrl = new URLSearchParams({ id: poi.exhibitId })
if (stopId) detailUrl.set('stopId', stopId)
return {
poiId: poi.id,
floorId: poi.floorId,
detailType: 'exhibit',
detailAvailable: true,
detailId: poi.exhibitId,
detailId: stopId || poi.exhibitId,
exhibitId: poi.exhibitId,
url: createCanonicalDetailUrl(`/pages/exhibit/detail?id=${encodeURIComponent(poi.exhibitId)}`, poi),
stopId,
// Keep the legacy ID only for history/static-data compatibility when an
// older POI record cannot be associated with a guide stop.
url: createCanonicalDetailUrl(`/pages/exhibit/detail?${detailUrl.toString()}`, poi),
failureMessage: '展品详情打开失败,请重试'
}
}

View File

@@ -0,0 +1,31 @@
export interface ExplainCodeEntryTarget {
code: string
stopId: string
hallName?: string
}
export const normalizeExplainCode = (code: string) => code.trim()
export const explainCodeErrorMessage = (error: unknown) => {
const code = error && typeof error === 'object' && 'code' in error
? Number((error as { code?: unknown }).code)
: undefined
if (code === 1020007000) return '请输入有效的 4 位讲解编号'
if (code === 1020007001) return '未找到该讲解编号,请确认后重试'
if (code === 1020007002) return '该编号的讲解内容暂不可用'
if (code === 429) return '查询过于频繁,请稍后再试'
return '编号查询失败,请检查网络后重试'
}
export const explainCodeDetailUrl = (target: ExplainCodeEntryTarget) => {
const params = new URLSearchParams({
id: target.stopId,
stopId: target.stopId,
tab: 'explain',
autoplay: '1',
entry: 'code',
guideCode: target.code
})
if (target.hallName) params.set('hallName', target.hallName)
return `/pages/exhibit/detail?${params.toString()}`
}

View File

@@ -4,24 +4,6 @@ import {
const isAbsoluteHttpUrl = (url: string) => /^https?:\/\//i.test(url)
const defaultSameOriginAssetHosts = [
['1', '92', '206', '90'].join('.')
]
const defaultLegacyAudioHosts = [
['47', '120', '48', '148'].join('.')
]
const configuredSameOriginAssetHosts = () => [
dataSourceConfig.publicSameOriginAssetHost,
...defaultSameOriginAssetHosts
].filter(Boolean)
const configuredLegacyAudioHosts = () => [
dataSourceConfig.publicLegacyAudioHost,
...defaultLegacyAudioHosts
].filter(Boolean)
const resolveSdkMinioProxyBaseUrl = () => {
const apiBaseUrl = dataSourceConfig.apiBaseUrl.trim().replace(/\/+$/, '')
const normalizedApiBaseUrl = apiBaseUrl.endsWith('/app-api')
@@ -50,11 +32,8 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
try {
const parsed = new URL(value)
const isSameServer = configuredSameOriginAssetHosts().includes(parsed.hostname)
if (
isSameServer
&& parsed.port === '9000'
parsed.port === '9000'
&& (
parsed.pathname.startsWith('/museum-assets/')
|| parsed.pathname.startsWith('/tts-audio/')
@@ -64,16 +43,14 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
}
if (
isSameServer
&& parsed.port === '48080'
parsed.port === '48080'
&& parsed.pathname.startsWith('/yudao-server/')
) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
if (
configuredLegacyAudioHosts().includes(parsed.hostname)
&& parsed.port === '19000'
parsed.port === '19000'
&& parsed.pathname.startsWith('/nhm/audio/')
) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`

View File

@@ -68,6 +68,10 @@ export interface ExplainDetailPageViewModel {
supportedLanguages?: string[]
languageLabel?: string
audioOptions?: MuseumGuideAudioOption[]
textVariants?: import('@/domain/museum').MuseumGuideTextVariant[]
recommendedTrackCode?: string
version?: 'standard' | 'extended'
stopId?: string
}
imageStatus?: string
galleryStatusText?: string
@@ -238,7 +242,11 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
statusText: hasPlayableAudio ? '可播放' : '当前语言暂无语音讲解',
supportedLanguages: exhibit.supportedLanguages,
languageLabel: languageLabelFor(exhibit.audioLanguage),
audioOptions: exhibit.audioOptions
audioOptions: exhibit.audioOptions,
textVariants: exhibit.textVariants,
recommendedTrackCode: exhibit.recommendedTrackCode,
version: exhibit.guideVersion,
stopId: exhibit.resolvedStopId || exhibit.playTargetId || exhibit.id
},
imageStatus: exhibit.imageStatus,
galleryStatusText: coverImages.length > 1 ? `${coverImages.length} 张讲解图片` : undefined,

View File

@@ -1,47 +1,133 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DefaultAudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
const stopInfoResponse = {
const detailResponse = {
code: 0,
data: {
available: true,
targetType: 'STOP',
targetId: 'stop-1',
resolvedStopId: 'stop-1',
lang: 'zh-CN',
title: '测试讲解点',
description: '测试简介',
imageStatus: 'READY',
imageSource: 'STOP',
galleryUrls: '[]',
playTargetType: 'STOP',
playTargetId: 'stop-1',
hasAudio: false,
id: '865546647764037632',
version: 'standard',
name: '测试讲解点',
imageStatus: 'MISSING',
textVariants: [{ version: 'standard', languageCode: 'zh-CN', text: '测试正文' }],
audioTracks: [{
version: 'standard', languageCode: 'zh-CN', gender: 'female',
playUrl: '/museum-assets/audio/zh-female.mp3'
}],
hasText: true,
supportedLanguages: ['zh-CN'],
audioStatus: 'MISSING',
audioOptions: []
hasAudio: true
}
}
describe('DefaultAudioPlayInfoRepository stop-info loading mode', () => {
describe('DefaultAudioPlayInfoRepository unified stop detail', () => {
afterEach(() => vi.unstubAllGlobals())
it('defaults H5 detail requests to lightweight mode and keeps its cache separate from legacy mode', async () => {
it('requests the versioned endpoint and caches each detail version independently', async () => {
const requestUrls: string[] = []
vi.stubGlobal('uni', {
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
requestUrls.push(url)
success({ statusCode: 200, data: stopInfoResponse })
success({
statusCode: 200,
data: {
...detailResponse,
data: {
...detailResponse.data,
version: String(url).includes('version=extended') ? 'extended' : 'standard'
}
}
})
}
})
const repository = new DefaultAudioPlayInfoRepository()
await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN' })
await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN', lightweight: false })
const first = await repository.getStopDetail('865546647764037632')
const second = await repository.getStopDetail('865546647764037632')
const extended = await repository.getStopDetail('865546647764037632', { version: 'extended' })
expect(requestUrls).toHaveLength(2)
expect(new URL(requestUrls[0], 'http://localhost').searchParams.get('lightweight')).toBe('true')
expect(new URL(requestUrls[1], 'http://localhost').searchParams.get('lightweight')).toBe('false')
expect(first.id).toBe('865546647764037632')
expect(second).toBe(first)
expect(extended).not.toBe(first)
expect(extended.version).toBe('extended')
expect(requestUrls).toEqual([
'/app-api/gis/guide/stops/865546647764037632?version=standard',
'/app-api/gis/guide/stops/865546647764037632?version=extended'
])
expect(first.audioTracks[0]?.channelCode).toBe('standard.zh-CN.female')
})
it('bypasses a completed entry only when refresh is explicitly requested', async () => {
const request = vi.fn(({ success }: { success: (response: unknown) => void }) => {
success({ statusCode: 200, data: detailResponse })
})
vi.stubGlobal('uni', { request })
const repository = new DefaultAudioPlayInfoRepository()
await repository.getStopDetail('stop-1')
await repository.getStopDetail('stop-1', { refresh: true })
expect(request).toHaveBeenCalledTimes(2)
})
it('resolves a museum code through info-by-code and preserves the code string', async () => {
const requestUrls: string[] = []
vi.stubGlobal('uni', {
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
requestUrls.push(url)
success({
statusCode: 200,
data: {
code: 0,
data: {
available: true,
targetType: 'STOP',
targetId: '865546652298080256',
resolvedStopId: '865546652298080256',
lang: 'zh-CN',
title: '手持折射望远镜',
imageStatus: 'READY',
playTargetType: 'STOP',
playTargetId: '865546652298080256',
hasAudio: true,
supportedLanguages: ['zh-CN', 'yue-HK'],
audioStatus: 'READY'
}
}
})
}
})
const repository = new DefaultAudioPlayInfoRepository()
const result = await repository.getStopInfoByCode('0101', 'zh-CN')
expect(requestUrls).toEqual([
'/app-api/gis/guide/stop/info-by-code?code=0101&lang=zh-CN'
])
expect(result.resolvedStopId).toBe('865546652298080256')
expect(result.title).toBe('手持折射望远镜')
})
it('preserves info-by-code business error codes', async () => {
vi.stubGlobal('uni', {
request: ({ success }: { success: (response: unknown) => void }) => {
success({ statusCode: 200, data: { code: 1020007001, msg: '馆方讲解点编号不存在', data: null } })
}
})
const repository = new DefaultAudioPlayInfoRepository()
await expect(repository.getStopInfoByCode('9999')).rejects.toMatchObject({
message: '馆方讲解点编号不存在',
code: 1020007001
})
})
it('surfaces a business missing-detail response as a detail error', async () => {
vi.stubGlobal('uni', {
request: ({ success }: { success: (response: unknown) => void }) => {
success({ statusCode: 200, data: { code: 1020005000, msg: '导览讲解点不存在' } })
}
})
const repository = new DefaultAudioPlayInfoRepository()
await expect(repository.getStopDetail('not-found')).rejects.toThrow('导览讲解点不存在')
})
})

View File

@@ -10,7 +10,10 @@ const installRequest = (handler: (url: string) => unknown) => {
}
describe('BackendExplainContentProvider hall stop paging', () => {
afterEach(() => vi.unstubAllGlobals())
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('uses the paged hall endpoint and maps independent pages without outlines', async () => {
const requests: string[] = []
@@ -23,8 +26,8 @@ describe('BackendExplainContentProvider hall stop paging', () => {
data: {
total: 30,
list: pageNo === '1'
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', playTargetType: 'STOP', playTargetId: 'target-1' }]
: [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', playTargetType: 'STOP', playTargetId: 'target-2' }]
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', outlineId: 'outline-1', linkedExhibits: [] }]
: [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', outlineId: 'outline-1', linkedExhibits: [] }]
}
}
})
@@ -35,7 +38,7 @@ describe('BackendExplainContentProvider hall stop paging', () => {
])
expect(first).toMatchObject({ total: 30, pageNo: 1, pageSize: 20, hasMore: true })
expect(first.items[0]).toMatchObject({ id: 'stop-1', hallName: '恐龙厅', playTargetId: 'target-1' })
expect(first.items[0]).toMatchObject({ id: 'stop-1', stopId: 'stop-1', hallName: '恐龙厅', outlineId: 'outline-1' })
expect(second.items.map((item) => item.id)).toEqual(['stop-2'])
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(2)
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
@@ -79,4 +82,25 @@ describe('BackendExplainContentProvider hall stop paging', () => {
expect(requests.filter((url) => url.includes('/catalog/halls?'))).toHaveLength(1)
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(1)
})
it('warns when a hall has guide stops but every compatibility outline reports zero', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
installRequest((url) => {
if (url.includes('/catalog/halls?')) {
return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', stopCount: 2 }] }
}
if (url.includes('/catalog/halls/hall-1/outlines?')) {
return { code: 0, data: [{ id: 'outline-1', name: '单元一', hallId: 'hall-1', stopCount: 0 }] }
}
throw new Error(`Unexpected request: ${url}`)
})
const units = await new BackendExplainContentProvider().listTemporaryBusinessUnitsByHall('hall-1')
expect(units).toHaveLength(1)
expect(warn).toHaveBeenCalledWith(
'一级单元与讲解点关联异常,一级单元仅作为兼容入口:',
expect.objectContaining({ hallId: 'hall-1', hallStopCount: 2 })
)
})
})

View File

@@ -256,6 +256,26 @@ describe('讲解详情音频优先布局', () => {
}), expect.objectContaining({ displayMode: 'mini' }))
})
it('编号入口加载详情后自动发起一次播放', async () => {
mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({
id: exhibit.id,
stopId: 'stop-1',
autoplay: '1',
entry: 'code'
})
await flushPromises()
expect(mocks.play).toHaveBeenCalledTimes(1)
expect(mocks.play).toHaveBeenCalledWith(
expect.objectContaining({ audioUrl: exhibit.audioUrl }),
expect.objectContaining({ displayMode: 'mini' })
)
})
it('加载中使用同一高度底栏和不确定进度', async () => {
audioState.currentAudio.value = { id: 'audio-1' }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
@@ -295,7 +315,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-audio-status').exists()).toBe(false)
})
it('播放失败显示错误并通过既有刷新链路重试', async () => {
it('播放失败显示错误并从当前详情音轨重试', async () => {
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
audioState.error.value = '音频暂时无法播放'
@@ -316,7 +336,10 @@ describe('讲解详情音频优先布局', () => {
await wrapper.get('.detail-audio-play').trigger('tap')
await flushPromises()
expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true })
expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.objectContaining({
id: exhibit.id,
guideVersion: 'standard'
}))
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({
retryOnError: expect.any(Function),
displayMode: 'mini'
@@ -349,13 +372,13 @@ describe('讲解详情音频优先布局', () => {
await flushPromises()
expect(wrapper.get('.detail-audio-speed').text()).toBe('1.0')
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为声讲解')
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为声讲解')
await wrapper.get('.detail-audio-speed').trigger('tap')
await wrapper.get('.detail-audio-voice').trigger('tap')
expect(mocks.setPlaybackRate).toHaveBeenCalledWith(1.25)
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为声讲解')
expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为声讲解')
expect(mocks.toggleMute).not.toHaveBeenCalled()
})
@@ -407,9 +430,6 @@ describe('讲解详情音频优先布局', () => {
})
await flushPromises()
vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => {
options.fail?.()
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
@@ -418,6 +438,6 @@ describe('讲解详情音频优先布局', () => {
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.navigateBack).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import {
explainCodeDetailUrl,
explainCodeErrorMessage,
normalizeExplainCode
} from '@/utils/explainCodeEntry'
describe('讲解编号入口', () => {
it('清理首尾空格但保留编号前导零', () => {
expect(normalizeExplainCode(' 0231 ')).toBe('0231')
})
it.each([
[1020007000, '请输入有效的 4 位讲解编号'],
[1020007001, '未找到该讲解编号,请确认后重试'],
[1020007002, '该编号的讲解内容暂不可用'],
[429, '查询过于频繁,请稍后再试']
])('将业务错误码 %s 转换为明确提示', (code, message) => {
expect(explainCodeErrorMessage({ code })).toBe(message)
})
it('未知错误使用网络重试提示', () => {
expect(explainCodeErrorMessage(new Error('offline'))).toBe('编号查询失败,请检查网络后重试')
})
it('详情路由携带讲解点和自动播放意图', () => {
const url = explainCodeDetailUrl({ code: '1001', stopId: '865546652298080256' })
expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
expect(url).toContain('stopId=865546652298080256')
expect(url).toContain('autoplay=1')
expect(url).toContain('entry=code')
expect(url).toContain('guideCode=1001')
})
})

View File

@@ -22,7 +22,7 @@ describe('ExplainGuideStopCatalog', () => {
expect(wrapper.findAll('.stop-status')).toHaveLength(3)
expect(wrapper.text()).toContain('讲解')
expect(wrapper.text()).toContain('图文')
expect(wrapper.text()).toContain('暂无内容')
expect(wrapper.text()).not.toContain('暂无内容')
expect(wrapper.find('.hall-arrow').exists()).toBe(false)
expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
})

View File

@@ -110,8 +110,7 @@ describe('讲解对象列表返回', () => {
expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅')
expect(params.get('targetType')).toBe('STOP')
expect(params.get('targetId')).toBe('target-1')
expect(params.get('stopId')).toBe('stop-1')
wrapper.unmount()
})

View File

@@ -67,6 +67,51 @@ describe('讲解展厅选择列表', () => {
expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
})
it('展厅阶段可输入讲解编号并保留前导零', async () => {
const wrapper = mount(ExplainHallSelect, {
props: { stage: 'hall' }
})
expect(wrapper.get('.code-entry-title').text()).toBe('编号讲解')
expect(wrapper.get('.hall-section-title').text()).toBe('按展厅选择')
expect(wrapper.find('.code-entry-arrow').exists()).toBe(false)
await wrapper.get('.code-entry-button').trigger('tap')
expect(wrapper.get('.header-title').text()).toBe('编号讲解')
expect(wrapper.find('.code-entry-close').exists()).toBe(false)
expect(wrapper.get('.code-entry-sheet-title').text()).toBe('输入讲解编号')
expect(wrapper.findAll('.code-digit-cell')).toHaveLength(4)
expect(wrapper.text()).toContain('请输入展品标签上的四位编号')
await wrapper.get('.code-entry-native-input').trigger('input', { detail: { value: '02a' } })
expect(wrapper.get('.code-entry-action.primary').attributes('disabled')).toBeDefined()
await wrapper.get('.code-entry-native-input').trigger('confirm')
expect(wrapper.get('.code-entry-error').text()).toBe('请输入完整的 4 位讲解编号')
await wrapper.get('.code-entry-native-input').trigger('input', { detail: { value: '0231' } })
expect(wrapper.findAll('.code-digit-value').map((item) => item.text())).toEqual(['0', '2', '3', '1'])
await wrapper.get('.code-entry-action.primary').trigger('tap')
expect(wrapper.emitted('codeSubmit')).toEqual([['0231']])
expect(wrapper.find('.code-entry-sheet').exists()).toBe(true)
await wrapper.setProps({ codeSubmitting: true })
expect(wrapper.get('.code-entry-native-input').attributes('disabled')).toBeDefined()
expect(wrapper.get('.code-entry-action.primary').text()).toBe('正在查询')
wrapper.vm.showCodeEntryError('未找到该讲解编号,请确认后重试')
await wrapper.vm.$nextTick()
expect(wrapper.get('.code-entry-error').text()).toBe('未找到该讲解编号,请确认后重试')
await wrapper.get('.header-back').trigger('tap')
expect(wrapper.find('.code-entry-sheet').exists()).toBe(false)
expect(wrapper.get('.header-title').text()).toBe('免费讲解')
expect(wrapper.emitted('back')).toBeUndefined()
await wrapper.get('.header-back').trigger('tap')
expect(wrapper.emitted('back')).toEqual([[]])
})
it('展厅阶段使用完整展厅卡片资源', () => {
const wrapper = mount(ExplainHallSelect, {
props: {
@@ -84,9 +129,9 @@ describe('讲解展厅选择列表', () => {
const card = wrapper.get('.hall-overview-card')
expect(wrapper.get('.header-title').text()).toBe('免费讲解')
expect(card.attributes('style')).toContain('--hall-card-color: #d9b06c')
expect(card.attributes('style')).toContain('--hall-card-color: #bb965a')
expect(card.get('.hall-full-card-image').attributes('src'))
.toBe('/static/icons/halls/custom-cards/dinosaur.png')
.toBe('/static/icons/halls/portal-icons/dinosaur.png')
expect(card.find('.hall-overview-copy').exists()).toBe(false)
expect(card.find('.hall-go-entry').exists()).toBe(false)
})

View File

@@ -1,102 +1,106 @@
import { describe, expect, it, vi } from 'vitest'
import type { GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
import type { GuideStopDetail } from '@/data/adapters/guideStopInfoAdapter'
import type { AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
import type { ExplainRepository } from '@/repositories/ExplainRepository'
import { ExplainUseCase } from '@/usecases/explainUseCase'
import { dataSourceConfig } from '@/config/dataSource'
const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo => ({
available: true,
targetType: 'STOP',
targetId: 'stop-1',
lang: 'zh-CN',
title: 'Guide stop',
const createDetail = (overrides: Partial<GuideStopDetail> = {}): GuideStopDetail => ({
id: 'stop-1',
version: 'standard',
name: 'Guide stop',
galleryUrls: [],
imageStatus: 'MISSING',
linkedExhibits: [
{
id: 'exhibit-1',
name: 'Linked exhibit',
coverImageUrl: '/linked-exhibit.jpg'
}
linkedExhibits: [{ id: 'exhibit-1', name: 'Linked exhibit', coverImageUrl: '/linked-exhibit.jpg' }],
isSharedStop: false,
textVariants: [
{ version: 'standard', languageCode: 'zh-CN', text: '普通话正文' },
{ version: 'standard', languageCode: 'en-US', text: 'English text' }
],
playTargetType: 'STOP',
playTargetId: 'stop-1',
hasAudio: false,
hasText: false,
supportedLanguages: [],
audioStatus: 'MISSING',
audioTracks: [
{ channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' },
{ channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/yue-female.mp3' }
],
hasText: true,
hasAudio: true,
textVariantCount: 2,
audioTrackCount: 2,
...overrides
})
const createUseCase = (stopInfo: GuideStopInfo) => {
const createUseCase = (detail: GuideStopDetail) => {
const explain = {
getExhibitById: vi.fn().mockResolvedValue(null),
listExplainExhibits: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const audioPlayInfo = {
getStopInfo: vi.fn().mockResolvedValue(stopInfo)
const audio = {
getStopDetail: vi.fn().mockResolvedValue(detail),
getStopInfoByCode: vi.fn().mockResolvedValue({
available: true,
resolvedStopId: 'resolved-stop-1',
playTargetId: 'resolved-stop-1',
targetId: 'resolved-stop-1'
})
} as unknown as AudioPlayInfoRepository
return new ExplainUseCase(explain, audioPlayInfo)
return { useCase: new ExplainUseCase(explain, audio), explain, audio }
}
describe('ExplainUseCase stop image policy', () => {
it('uses stop-info first in remote mode without requesting a catalog fallback', async () => {
const originalMode = dataSourceConfig.explainContentMode
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })
const explain = {
getExhibitById: vi.fn(),
listExplainExhibits: vi.fn(),
listExplainExhibitsByHall: vi.fn()
} as unknown as ExplainRepository
const audio = { getStopInfo: vi.fn().mockResolvedValue(createStopInfo()) } as unknown as AudioPlayInfoRepository
describe('ExplainUseCase unified stop detail', () => {
it('resolves the museum code to the backend stop ID', async () => {
const { useCase, audio } = createUseCase(createDetail())
await new ExplainUseCase(explain, audio).enterExplainDetail({
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', hallName: '宇宙厅'
const result = await useCase.resolveExplainCode('1001', 'zh-CN')
expect(audio.getStopInfoByCode).toHaveBeenCalledWith('1001', 'zh-CN')
expect(result).toEqual({ code: '1001', stopId: 'resolved-stop-1' })
})
expect(audio.getStopInfo).toHaveBeenCalledTimes(1)
it('uses stopId once in remote mode without requesting a catalog fallback', async () => {
const originalMode = dataSourceConfig.explainContentMode
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })
const { useCase, explain, audio } = createUseCase(createDetail())
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', hallName: '宇宙厅' })
expect(audio.getStopDetail).toHaveBeenCalledWith('stop-1', { version: 'standard' })
expect(explain.getExhibitById).not.toHaveBeenCalled()
expect(explain.listExplainExhibits).not.toHaveBeenCalled()
expect(result.hallName).toBe('宇宙厅')
Object.assign(dataSourceConfig, { explainContentMode: originalMode })
})
it('delegates hall exhibits and business-unit stops without loading full exhibits', async () => {
const explain = {
listExplainExhibitsByHall: vi.fn().mockResolvedValue([]),
listGuideStopsByBusinessUnit: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const useCase = new ExplainUseCase(explain, {} as AudioPlayInfoRepository)
await useCase.listExhibitsByHallId('hall-1')
await useCase.listGuideStopsByBusinessUnit('hall-1', 'outline-1')
expect(explain.listExplainExhibitsByHall).toHaveBeenCalledWith('hall-1')
expect(explain.listGuideStopsByBusinessUnit).toHaveBeenCalledWith('hall-1', 'outline-1')
})
it('does not use linked exhibit images when stop-info reports MISSING', async () => {
const detail = await createUseCase(createStopInfo()).enterExplainDetail({
exhibitId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1'
it('does not use linked exhibit images when detail reports MISSING', async () => {
const { useCase } = createUseCase(createDetail())
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' })
expect(result.image).toBeUndefined()
expect(result.galleryUrls).toEqual([])
expect(result.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
})
expect(detail.image).toBeUndefined()
expect(detail.galleryUrls).toEqual([])
expect(detail.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
it('uses local standard text and audio variants, with Chinese text fallback for Cantonese', async () => {
const { useCase, audio } = createUseCase(createDetail())
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', lang: 'yue-HK' })
const text = await useCase.loadExplainDetailText(result)
const selection = await useCase.selectAudioForExplainDetail(result, { voiceGender: 'male' })
expect(result.guideText).toBe('普通话正文')
expect(text.textInfo.text).toBe('普通话正文')
expect(selection.media?.url).toBe('/yue-female.mp3')
expect(audio.getStopDetail).toHaveBeenCalledTimes(1)
})
it('uses the stop image when stop-info reports READY', async () => {
const detail = await createUseCase(createStopInfo({
imageStatus: 'READY',
coverImageUrl: '/guide-stop.jpg',
galleryUrls: ['/guide-stop-gallery.jpg']
})).enterExplainDetail({
exhibitId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1'
})
it('uses the recommended track for the current detail version by default', async () => {
const { useCase } = createUseCase(createDetail({
recommendedTrackCode: 'standard.zh-CN.female',
audioTracks: [
{ channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' },
{ channelCode: 'standard.zh-CN.male', version: 'standard', languageCode: 'zh-CN', gender: 'male', playUrl: '/zh-male.mp3' }
]
}))
expect(detail.image).toBe('/guide-stop.jpg')
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' })
const selection = await useCase.selectAudioForExplainDetail(result)
expect(selection.media?.url).toBe('/zh-female.mp3')
})
})

View File

@@ -1,18 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const repositoryMocks = vi.hoisted(() => ({
getPlayInfo: vi.fn()
}))
vi.mock('@/repositories/AudioPlayInfoRepository', () => ({
audioPlayInfoRepository: {
getPlayInfo: repositoryMocks.getPlayInfo
},
audioReasonToText: () => '当前语言暂无语音讲解'
}))
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
describe('全局讲解播放器语言切换', () => {
@@ -20,10 +8,7 @@ describe('全局讲解播放器语言切换', () => {
beforeEach(() => {
player.close()
repositoryMocks.getPlayInfo.mockReset()
vi.stubGlobal('uni', {
showToast: vi.fn()
})
vi.stubGlobal('uni', { showToast: vi.fn() })
vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined)
@@ -35,18 +20,9 @@ describe('全局讲解播放器语言切换', () => {
vi.unstubAllGlobals()
})
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => {
repositoryMocks.getPlayInfo.mockResolvedValue({
playable: true,
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'yue-HK',
audioId: 'cantonese-audio',
playUrl: '/museum-assets/audio/cantonese.mp3',
hasText: true,
fallback: false
})
it('切换到粤语时只从当前统一详情的音轨数组选择,不产生额外 HTTP 请求', async () => {
const request = vi.fn()
vi.stubGlobal('uni', { request, showToast: vi.fn() })
await player.play({
id: 'mandarin-audio',
name: '测试讲解',
@@ -54,28 +30,25 @@ describe('全局讲解播放器语言切换', () => {
language: 'zh-CN'
}, {
source: {
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'zh-CN'
stopId: '1823450596800289',
lang: 'zh-CN',
voiceGender: 'male',
audioOptions: [
{ channelCode: 'standard.zh-CN.male', version: 'standard', languageCode: 'zh-CN', gender: 'male', playUrl: '/museum-assets/audio/mandarin.mp3' },
{ channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/museum-assets/audio/cantonese.mp3' }
]
}
})
const pauseSpy = vi.mocked(window.HTMLMediaElement.prototype.pause)
pauseSpy.mockClear()
const switching = player.switchLanguage('yue-HK')
expect(pauseSpy).toHaveBeenCalledTimes(1)
expect(player.playing.value).toBe(false)
expect(player.currentTime.value).toBe(0)
expect(repositoryMocks.getPlayInfo).toHaveBeenCalledWith(expect.objectContaining({
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'yue-HK',
refresh: true
}))
await expect(switching).resolves.toBe(true)
expect(player.currentSource.value?.lang).toBe('yue-HK')
expect(request).not.toHaveBeenCalled()
expect(player.currentSource.value).toEqual(expect.objectContaining({
stopId: '1823450596800289',
lang: 'yue-HK',
voiceGender: 'female'
}))
expect(player.currentAudio.value).toEqual(expect.objectContaining({
language: 'yue-HK',
audioUrl: '/museum-assets/audio/cantonese.mp3'

View File

@@ -1,137 +1,67 @@
import { describe, expect, it } from 'vitest'
import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
import { toGuideStopDetail } from '@/data/adapters/guideStopInfoAdapter'
describe('guide stop-info adapter', () => {
it('normalizes aggregated language variants and exposes only displayable languages', () => {
const result = toGuideStopInfo({
available: true,
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN',
const languages = ['zh-CN', 'en-US', 'yue-HK'] as const
describe('unified guide stop detail adapter', () => {
it('preserves snowflake IDs, synthesizes stable track codes, and ignores images marked missing', () => {
const textVariants = languages.map((languageCode) => ({
version: 'standard',
languageCode,
text: `standard-${languageCode}`
}))
const audioTracks = languages.flatMap((languageCode) => (
languageCode === 'yue-HK'
? [{ version: 'standard', languageCode, gender: 'female', playUrl: `/standard-${languageCode}-female.mp3` }]
: ['female', 'male'].map((gender) => ({ version: 'standard', languageCode, gender, playUrl: `/standard-${languageCode}-${gender}.mp3` }))
))
const detail = toGuideStopDetail({
id: '865546647764037632',
version: 'standard',
outlineId: '7467940240901013505',
linkedExhibits: [{ id: '865546647764037633', name: '关联展品' }],
name: '青铜神树',
coverImageUrl: '/should-not-be-used.webp',
galleryUrls: '["/should-not-be-used-gallery.webp"]',
imageStatus: 'MISSING',
recommendedTrackCode: 'standard.zh-CN.female',
textVariants,
audioTracks,
textVariantCount: 3,
audioTrackCount: 5
})
expect(detail.id).toBe('865546647764037632')
expect(detail.version).toBe('standard')
expect(detail.outlineId).toBe('7467940240901013505')
expect(detail.linkedExhibits[0]?.id).toBe('865546647764037633')
expect(detail.coverImageUrl).toBeUndefined()
expect(detail.galleryUrls).toEqual([])
expect(detail.textVariants).toHaveLength(3)
expect(detail.audioTracks).toHaveLength(5)
expect(detail.recommendedTrackCode).toBe('standard.zh-CN.female')
expect(detail.audioTracks.find((track) => track.channelCode === 'standard.zh-CN.female')?.playUrl)
.toBe('/standard-zh-CN-female.mp3')
})
it('drops unusable tracks while retaining a Chinese text variant for Cantonese fallback', () => {
const detail = toGuideStopDetail({
id: 'stop-1',
version: 'extended',
name: '测试',
imageStatus: 'READY',
coverImageUrl: '/museum-assets/cover.webp',
languageVariants: [
{
lang: 'zh',
enabled: true,
playable: true,
audioStatus: 'READY',
playUrl: '/museum-assets/audio/zh.mp3',
duration: '50',
audioId: 12,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
textLength: '6',
textHash: 'zh-hash'
},
{
lang: 'yue-HK',
enabled: true,
playable: false,
audioStatus: 'MISSING',
textAvailable: true,
text: '粤语使用普通话文案',
reason: 'NO_PUBLISHED_AUDIO'
},
{
lang: 'en-US',
enabled: false,
playable: true,
playUrl: '/museum-assets/audio/en.mp3'
}
textVariants: [{ version: 'extended', languageCode: 'zh-CN', text: '普通话正文' }],
audioTracks: [
{ version: 'extended', languageCode: 'yue-HK', gender: 'female', playUrl: '/cantonese.mp3' },
{ version: 'extended', languageCode: 'zh-CN', gender: 'female' }
]
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'yue-HK'])
expect(result.languageVariants['zh-CN']).toMatchObject({
playable: true,
playUrl: '/museum-assets/audio/zh.mp3',
duration: 50,
audioId: '12',
text: '普通话讲解词',
textLength: 6
})
expect(result.languageVariants['yue-HK']).toMatchObject({
playable: false,
textAvailable: true,
reason: 'NO_PUBLISHED_AUDIO'
})
})
it('keeps supportedLanguages as the compatibility fallback when variants are absent', () => {
const result = toGuideStopInfo({
supportedLanguages: ['zh-CN', 'en-US']
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
expect(result.languageVariants).toEqual({})
})
it('uses playable audio options as the primary language and voice contract', () => {
const result = toGuideStopInfo({
supportedLanguages: ['zh-CN', 'yue-HK'],
audioOptions: [
{
channelCode: 'standard.zh-CN.male',
displayName: '普通话男声',
languageCode: 'zh-CN',
gender: 'male',
playUrl: '/museum-assets/audio/zh-male.mp3',
duration: '51',
isDefault: false,
sortOrder: 11
},
{
channelCode: 'standard.en-US.female',
displayName: '英文女声',
languageCode: 'en-US',
gender: 'female',
playUrl: '/museum-assets/audio/en-female.mp3',
duration: 49,
isDefault: true,
sortOrder: 20
},
{
channelCode: 'standard.zh-CN.female',
displayName: '普通话女声',
languageCode: 'zh-CN',
gender: 'female',
playUrl: '/museum-assets/audio/zh-female.mp3',
isDefault: true,
sortOrder: 10
},
{
channelCode: 'invalid',
languageCode: 'zh-CN',
expect(detail.version).toBe('extended')
expect(detail.textVariants[0]?.text).toBe('普通话正文')
expect(detail.audioTracks).toEqual([expect.objectContaining({
channelCode: 'extended.yue-HK.female',
gender: 'female'
}
]
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
expect(result.audioOptions.map((option) => option.channelCode)).toEqual([
'standard.zh-CN.female',
'standard.zh-CN.male',
'standard.en-US.female'
])
expect(result.audioOptions[0]).toMatchObject({
displayName: '普通话女声',
gender: 'female',
playUrl: '/museum-assets/audio/zh-female.mp3',
isDefault: true
})
})])
})
})

View File

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { explainUseCase } from '@/usecases/explainUseCase'
import { PoiDetailUseCase } from '@/usecases/poiDetailUseCase'
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
getExhibitById: vi.fn(),
listHalls: vi.fn()
}
}))
describe('PoiDetailUseCase', () => {
beforeEach(() => {
vi.mocked(explainUseCase.getExhibitById).mockReset()
})
it('resolves a POI exhibit reference to a guide stop before opening the unified detail route', async () => {
vi.mocked(explainUseCase.getExhibitById).mockResolvedValue({
id: 'stop-865546647764037632',
resolvedStopId: '865546647764037632'
} as never)
const target = await new PoiDetailUseCase().resolve({
id: 'poi-1',
name: '青铜神树',
floorId: 'floor-1',
exhibitId: 'exhibit-1'
})
const params = new URLSearchParams(target.url.split('?')[1])
expect(explainUseCase.getExhibitById).toHaveBeenCalledWith('exhibit-1')
expect(target.detailId).toBe('865546647764037632')
expect(target.stopId).toBe('865546647764037632')
expect(params.get('id')).toBe('exhibit-1')
expect(params.get('stopId')).toBe('865546647764037632')
})
})

View File

@@ -12,6 +12,20 @@ describe('normalizeSameOriginPublicUrl', () => {
)).toBe('/app-api/gis/sdk/minio/museum-assets/guide-final/E2/detail.jpg')
})
it('rewrites legacy absolute media and proxy URLs without embedding their host', () => {
expect(normalizeSameOriginPublicUrl(
'http://172.20.14.21:9000/museum-assets/guide-final/E2/detail.jpg'
)).toBe('/app-api/gis/sdk/minio/museum-assets/guide-final/E2/detail.jpg')
expect(normalizeSameOriginPublicUrl(
'http://172.20.14.21:48080/yudao-server/app-api/gis/guide?stopId=1'
)).toBe('/yudao-server/app-api/gis/guide?stopId=1')
expect(normalizeSameOriginPublicUrl(
'http://172.20.14.21:19000/nhm/audio/guide.mp3'
)).toBe('/nhm/audio/guide.mp3')
})
it('keeps ordinary relative paths unchanged', () => {
expect(normalizeSameOriginPublicUrl('/static/placeholder.png'))
.toBe('/static/placeholder.png')