Compare commits

...

4 Commits

Author SHA1 Message Date
lyf
d07d68af0a docs: 更新导览站点端口与证书部署说明
Some checks are pending
CI / verify (push) Waiting to run
2026-07-22 20:17:53 +08:00
lyf
5d0c38e726 修复讲解列表展厅标题
Some checks failed
CI / verify (push) Has been cancelled
2026-07-22 19:44:16 +08:00
lyf
4d8cc55a5c 适配讲解详情聚合接口 2026-07-22 19:40:17 +08:00
cxk
dd29ead041 docs: 更新正式环境部署手册
Some checks failed
CI / verify (push) Has been cancelled
2026-07-21 15:31:21 +08:00
14 changed files with 1202 additions and 159 deletions

View File

@@ -1,6 +1,6 @@
# frontend-miniapp 正式环境部署手册 # frontend-miniapp 正式环境部署手册
最后更新2026-07-15 最后更新2026-07-21
## 适用范围 ## 适用范围
@@ -35,6 +35,7 @@ git remote -v
正式环境当前依赖一组本地生产补丁,拉取远程代码前必须保护。常见补丁文件包括: 正式环境当前依赖一组本地生产补丁,拉取远程代码前必须保护。常见补丁文件包括:
```text ```text
.env.development
.env.production .env.production
src/config/dataSource.ts src/config/dataSource.ts
src/data/adapters/guideDataAdapter.ts src/data/adapters/guideDataAdapter.ts
@@ -49,19 +50,34 @@ static/guide-data/guide-stops.json
static/guide-data/manifest.json static/guide-data/manifest.json
``` ```
这些文件中的改动属于正式环境本地补丁,拉取远程代码时不要直接丢弃。当前正式环境相关配置要点:
- `VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST=172.20.14.21`,避免正式包混入测试服务器 `1.92.206.90`
- `VITE_EXPLAIN_ALLOW_STATIC_FALLBACK=true`,讲解数据接口异常时允许使用静态兜底数据。
- `VITE_SGS_SDK_ORIGIN=https://guide.sznhmuseum.org.cn`,正式域名不再使用 `:4430`
- `static/guide-data/manifest.json``sourceHost` 应为 `172.20.14.21`
- 腾讯地图正式 key 只通过环境变量 `VITE_TENCENT_MAP_KEY` 注入,不能写入文档或提交记录。
## 拉取远程最新代码 ## 拉取远程最新代码
先临时保存本地生产补丁: 先临时保存本地生产补丁:
```powershell ```powershell
$stamp = Get-Date -Format 'yyyyMMddHHmmss' $stamp = Get-Date -Format 'yyyyMMddHHmmss'
$msg = "pre-deploy-local-prod-fixes-$stamp" $msg = "pre-deploy-prod-local-fixes-$stamp"
if (git status --porcelain) { if (git status --porcelain) {
git stash push -u -m $msg git stash push -u -m $msg
Write-Output "STASHED=$msg" Write-Output "STASHED=$msg"
} }
``` ```
执行成功时会看到类似输出,表示本地补丁已 stash
```text
Saved working directory and index state On master: pre-deploy-prod-local-fixes-YYYYMMDDHHmmss
STASHED=pre-deploy-prod-local-fixes-YYYYMMDDHHmmss
```
拉取远程最新代码: 拉取远程最新代码:
```powershell ```powershell
@@ -83,6 +99,8 @@ git status --short --branch --untracked-files=all
如出现冲突,先解决冲突并重新确认生产配置,再继续构建。 如出现冲突,先解决冲突并重新确认生产配置,再继续构建。
恢复后重点检查 `.env.production``static/guide-data/manifest.json``src/utils/publicUrl.ts`,确认正式环境仍指向 `172.20.14.21``https://guide.sznhmuseum.org.cn`
## 本地构建 ## 本地构建
安装依赖并做类型检查: 安装依赖并做类型检查:
@@ -447,3 +465,4 @@ https://guide.sznhmuseum.org.cn/ HTTP 200
- 2026-07-13确认 `pnpm build:h5` 在 terser 阶段可能 OOM正式部署可使用 `build:h5:no-minify` 兜底。 - 2026-07-13确认 `pnpm build:h5` 在 terser 阶段可能 OOM正式部署可使用 `build:h5:no-minify` 兜底。
- 2026-07-13新增并验证 `static/icons/poi/shortcut-icons.svg` 线上可访问。 - 2026-07-13新增并验证 `static/icons/poi/shortcut-icons.svg` 线上可访问。
- 2026-07-15排查并修复页面 200 但空白问题。根因为 PowerShell 手工替换构建 JS 时破坏 UTF-8已新增 `scripts/finalize-h5-build.cjs` 统一处理资源复制、腾讯地图 key 替换、JS 语法校验和正式环境字符串扫描。 - 2026-07-15排查并修复页面 200 但空白问题。根因为 PowerShell 手工替换构建 JS 时破坏 UTF-8已新增 `scripts/finalize-h5-build.cjs` 统一处理资源复制、腾讯地图 key 替换、JS 语法校验和正式环境字符串扫描。
- 2026-07-20拉取远程最新代码前已执行 `git stash push -u -m pre-deploy-prod-local-fixes-20260720232941`,确认“本地补丁已 stash”随后快进到 `735c5cd`,执行 `git stash pop` 恢复正式环境补丁,无冲突。构建入口为 `assets/index-CsFOrYfU.js`,主节点备份为 `/data/nginx/html/_backups/mobile-before-deploy-20260720233343.tar.gz`,同步脚本最终输出 `All frontend worker nodes synced.`

View File

@@ -0,0 +1,718 @@
# guide.whaoyue.com 静态站点部署说明
## 1. 部署目标
- 域名:`guide.whaoyue.com`
- 服务器备注:自然博物馆-测试服务器
- 公网 IP`1.92.206.90`
- SSH 用户:`root`
- 系统Huawei Cloud EulerOS 2.0 aarch64
- 部署内容Nginx 静态测试页面 + Lets Encrypt HTTPS 证书
- 页面地址:<https://guide.whaoyue.com:4433/>
- 首次部署日期2026-06-08
- 端口调整日期2026-07-22
> 当前公网 HTTPS 入口是 `4433`,访问地址必须显式携带端口。`https://guide.whaoyue.com/` 使用默认 `443`,不再是当前入口。
## 2. 当前部署拓扑
服务器已安装系统级 Nginx 包。Docker Nginx 容器内仍监听标准 `80/443`,宿主机公网映射已调整为 `8888/4433`
```text
公网用户
-> guide.whaoyue.com
-> 1.92.206.90:8888/4433
-> Docker 容器 nginx-server
-> 宿主机 8888 -> 容器 80
-> 宿主机 4433 -> 容器 443
-> /dmdata/nginx/html/guide/index.html
```
Docker Nginx 容器:
```bash
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
```
当前端口映射:
| 宿主机端口 | 容器端口 | 用途 |
| --- | --- | --- |
| `8888` | `80` | HTTP 入口,跳转到 `https://guide.whaoyue.com:4433/` |
| `4433` | `443` | HTTPS 静态站点和反向代理 |
| `7080` | `7080` | 既有服务,端口调整时必须保留 |
| `7081` | `7081` | 既有服务,端口调整时必须保留 |
关键挂载:
| 宿主机路径 | 容器路径 | 用途 |
| --- | --- | --- |
| `/dmdata/nginx/conf.d` | `/etc/nginx/conf.d` | Nginx 站点配置 |
| `/dmdata/nginx/html` | `/usr/share/nginx/html` | 静态页面 |
| `/dmdata/nginx/ssl` | `/etc/nginx/ssl` | SSL 证书 |
| `/dmdata/nginx/logs` | `/var/log/nginx` | 访问日志和错误日志 |
## 3. 静态页面
静态测试页路径:
```bash
/dmdata/nginx/html/guide/index.html
```
页面用途:
- 验证域名解析
- 验证 Nginx 静态站点
- 验证 HTTP 到 HTTPS 跳转
- 验证 Lets Encrypt 证书
如需替换正式页面,将构建后的静态文件发布到:
```bash
/dmdata/nginx/html/guide/
```
## 4. Nginx 配置
站点配置文件:
```bash
/dmdata/nginx/conf.d/guide.whaoyue.com.conf
```
配置行为:
- `http://guide.whaoyue.com:8888/` 自动跳转到 `https://guide.whaoyue.com:4433/`
- `/.well-known/acme-challenge/` 路径仍保留,但 Lets Encrypt HTTP-01 不会验证 `8888`,续期限制见第 5 节
- `https://guide.whaoyue.com:4433/` 返回静态页面
- `https://guide.whaoyue.com:4433/app-api/` 反向代理到宿主机 `48080/yudao-server/app-api/`
- `https://guide.whaoyue.com:4433/h5-sdk` 配置为反向代理到宿主机 `3001`2026-07-22 公网抽查为 `502`,当前不可作为可用能力
- `https://guide.whaoyue.com:4433/museum-assets/``/minio/museum-assets/` 反向代理到 MinIO
- 静态资源设置 30 天缓存
### 4.1 H5 SDK 与后端代理规则
H5 页面运行在 HTTPS 下,前端运行时代码不能直接请求:
```text
http://1.92.206.90:3001/app-api/...
http://1.92.206.90:9000/museum-assets/...
```
否则浏览器会拦截 mixed content表现为 SDK 后端接口请求失败或 3D 模型资源 `Failed to fetch`
前端 H5 构建环境应使用同源路径:
```dotenv
VITE_DATA_SOURCE_MODE=sdk
VITE_API_BASE_URL=/app-api
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js
VITE_SGS_H5_ENGINE_URL=/h5-sdk
VITE_SGS_SDK_ORIGIN=https://guide.whaoyue.com:4433
```
`VITE_SGS_H5_ENGINE_URL=/h5-sdk` 仅在 H5 SDK 上游恢复后可用2026-07-22 公网响应为 `502`
Nginx 容器内的 `443 ssl` server 中必须包含以下代理规则。宿主机 `4433` 通过 Docker 映射进入容器 `443`
```nginx
location ^~ /app-api/ {
proxy_pass http://172.17.0.1:48080/yudao-server/app-api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location ^~ /h5-sdk {
proxy_pass http://172.17.0.1:3001/h5-sdk;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location ^~ /minio/museum-assets/ {
proxy_pass http://172.17.0.1:9000/museum-assets/;
proxy_set_header Host 172.17.0.1:9000;
expires 30d;
add_header Cache-Control "public";
}
location ^~ /museum-assets/ {
proxy_pass http://172.17.0.1:9000/museum-assets/;
proxy_set_header Host 172.17.0.1:9000;
expires 30d;
add_header Cache-Control "public";
}
```
每次修改后检查并重载:
```bash
docker exec nginx-server nginx -t
docker exec nginx-server nginx -s reload
```
检查配置:
```bash
docker exec nginx-server nginx -t
```
重载配置:
```bash
docker exec nginx-server nginx -s reload
```
重启容器:
```bash
docker restart nginx-server
```
### 4.2 Docker 端口映射
当前容器没有 compose 管理,端口映射保存在容器启动参数中。检查当前值:
```bash
docker inspect nginx-server --format '{{json .HostConfig.PortBindings}}'
```
期望结果包含:
```text
宿主机 8888 -> 容器 80
宿主机 4433 -> 容器 443
宿主机 7080 -> 容器 7080
宿主机 7081 -> 容器 7081
```
需要重建容器时,先保存检查信息并保留旧容器用于回滚:
```bash
ts=$(date +%Y%m%d%H%M%S)
docker inspect nginx-server \
> "/dmdata/nginx/backups/nginx-server-inspect-before-port-change-${ts}.json"
docker stop nginx-server
docker rename nginx-server "nginx-server-before-port-change-${ts}"
docker run -d \
--name nginx-server \
--restart unless-stopped \
-p 8888:80 \
-p 4433:443 \
-p 7080:7080 \
-p 7081:7081 \
-v /dmdata/nginx/conf.d:/etc/nginx/conf.d:ro \
-v /dmdata/nginx/ssl:/etc/nginx/ssl:ro \
-v /dmdata/nginx/html:/usr/share/nginx/html:ro \
-v /dmdata/nginx/logs:/var/log/nginx \
arm64v8/nginx:stable-alpine
```
服务器防火墙必须持久化放行新端口:
```bash
firewall-cmd --permanent --add-port=8888/tcp
firewall-cmd --permanent --add-port=4433/tcp
firewall-cmd --reload
```
启动后先执行 `nginx -t` 和第 6 节公网验证。全部通过后再删除已停止的旧容器。
## 5. SSL 证书
证书客户端:`acme.sh`
证书颁发机构Lets Encrypt
证书路径:
```bash
/dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem
/dmdata/nginx/ssl/guide.whaoyue.com/privkey.pem
```
证书信息:
```text
Subject: CN = guide.whaoyue.com
Issuer: Let's Encrypt YR2
Not Before: 2026-06-07 15:33:10 GMT
Not After: 2026-09-05 15:33:09 GMT
```
查看证书:
```bash
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -subject -issuer -dates
```
自动续期:
```bash
crontab -l | grep acme
```
当前续期任务:
```text
11 7 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null
```
当前证书配置使用 Webroot HTTP-01
```text
Le_Webroot='/dmdata/nginx/html/guide'
```
Lets Encrypt HTTP-01 固定验证公网 `80`,不会访问 `8888`。因此改为 `8888/4433`cron 存在不代表证书能够自动续期。必须在证书到期前完成以下任一方案:
1. 续期窗口临时恢复安全组、防火墙和 Docker 的公网 `80 -> 容器 80` 映射,续期完成后再关闭。
2.`acme.sh` 迁移为 DNS-01并配置可用的 DNS API 凭据。
当前证书到期时间为 `2026-09-05 15:33:09 GMT`,迁移或续期测试应提前完成。
`acme.sh` 已配置证书安装后的 reload 命令:
```bash
docker exec nginx-server nginx -s reload
```
满足公网 `80` 可验证或已迁移 DNS-01 后,才可执行手动续期测试:
```bash
/root/.acme.sh/acme.sh --cron --home /root/.acme.sh --force
```
测试后必须重新检查证书到期时间,并从公网验证 `4433`
```bash
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -dates
curl -I https://guide.whaoyue.com:4433/
```
## 6. 部署步骤复盘
1. 确认 DNS
```powershell
Resolve-DnsName guide.whaoyue.com -Type A
```
结果应指向:
```text
1.92.206.90
```
2. 确认公网端口:
```powershell
Test-NetConnection guide.whaoyue.com -Port 8888
Test-NetConnection guide.whaoyue.com -Port 4433
```
3. 创建静态测试页:
```bash
mkdir -p /dmdata/nginx/html/guide/.well-known/acme-challenge
```
4. 写入 Nginx 站点配置:
```bash
/dmdata/nginx/conf.d/guide.whaoyue.com.conf
```
5. 申请证书(以下是 2026-06-08 使用公网 `80` 完成首次签发的历史命令;当前端口映射下不可直接用于 HTTP-01 续期):
```bash
/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt
/root/.acme.sh/acme.sh --issue --server letsencrypt -d guide.whaoyue.com -w /dmdata/nginx/html/guide --keylength 2048
```
6. 安装证书:
```bash
/root/.acme.sh/acme.sh --install-cert -d guide.whaoyue.com \
--key-file /dmdata/nginx/ssl/guide.whaoyue.com/privkey.pem \
--fullchain-file /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem \
--reloadcmd "docker exec nginx-server nginx -s reload"
```
7. 验证访问:
```powershell
curl.exe -I http://guide.whaoyue.com:8888/
curl.exe -I https://guide.whaoyue.com:4433/
curl.exe -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl.exe -I https://guide.whaoyue.com:4433/h5-sdk
curl.exe -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
curl.exe -I https://guide.whaoyue.com:4433/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
```
期望结果:
```text
HTTP 301 -> https://guide.whaoyue.com:4433/
HTTPS 200 OK
/app-api/... 200 application/json
/h5-sdk 当前为 502修复上游或移除废弃规则前不得记为通过
/static/sgs-map-sdk/index.global.js 200 application/javascript
模型 GLB 200 model/gltf-binary
```
## 7. 已处理的问题
本节中的无端口 URL、404 和 Mixed Content 文本是端口调整前的历史故障原文;当前复测命令统一使用 `8888/4433`
### 7.1 Docker Nginx 配置校验失败
容器内 `nginx -t` 曾失败,原因是 Linux Docker 容器无法解析 `host.docker.internal`
处理方式:
-`/dmdata/nginx/conf.d/https.conf` 中的 `host.docker.internal` 代理目标改为 Docker 默认网关 `172.17.0.1`
- 重新执行 `docker exec nginx-server nginx -t`
- 配置校验通过后 reload Nginx
### 7.2 公网端口调整为 8888/4433
系统级 Nginx 已安装,但站点继续由 `nginx-server` 容器承载。2026-07-22 将宿主机映射从 `80:80``443:443` 调整为 `8888:80``4433:443`,并保留 `7080/7081` 映射。Nginx 容器内仍监听标准 `80/443`
HTTP server 的跳转目标必须显式包含新 HTTPS 端口:
```nginx
return 301 https://$host:4433$request_uri;
```
### 7.3 H5 SDK 接口请求混合内容
现象:
```text
Request URL http://1.92.206.90:3001/app-api/gis/sdk/maps/1/manifest
Referrer Policy strict-origin-when-cross-origin
```
页面实际运行在 `https://guide.whaoyue.com:4433/`,但构建产物中固化了 HTTP 后端地址,浏览器会拦截 mixed content 请求。若 Nginx 未配置 `/app-api/` 代理,改成同源路径后还会被 SPA 回退到 `index.html`
处理方式:
- 前端构建环境改为同源 `/app-api``/h5-sdk``/static/sgs-map-sdk/index.global.js`
- Nginx 增加 `/app-api/``/h5-sdk` 代理到宿主机 `3001`
- 重新构建并部署 H5 包
- 扫描构建产物,确认运行时代码不再包含 `1.92.206.90:3001`
验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl.exe -I https://guide.whaoyue.com:4433/h5-sdk
curl.exe -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
```
### 7.4 SDK 模型资源请求混合内容
现象:
- SDK 接口已经返回 `200 JSON`
- 页面仍提示 `3D 模型加载失败``Failed to fetch`
- 后端 bundle 中部分 `modelUrl``http://1.92.206.90:9000/museum-assets/...`
原因:
HTTPS 页面加载 HTTP MinIO 模型资源会被浏览器拦截;另外后端也可能返回 `/minio/museum-assets/...`,如果 Nginx 未配置该前缀,会落入 SPA 回退并返回 `index.html`
处理方式:
- Nginx 同时配置 `/museum-assets/``/minio/museum-assets/` 到 MinIO
- 前端对已知 `http://1.92.206.90:9000/museum-assets/...` 模型地址改写为同源 `/museum-assets/...`
- 重新构建并部署 H5 包
验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
```
期望均返回:
```text
HTTP/1.1 200 OK
Content-Type: model/gltf-binary
```
### 7.5 展厅图标与占位图静态资源 404
现象:
```text
GET https://guide.whaoyue.com/static/icons/halls/biology.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/evolution.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/dinosaur.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/earth.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/human.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/homeland.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/universe.jpg 404
GET https://guide.whaoyue.com/static/exhibit-placeholder.jpg 404
```
原因:
- 源码目录存在 `static/icons/halls/*.jpg`,但 H5 构建后的补充复制脚本只复制了 `nav-assets``guide-data``sgs-map-sdk``three`,没有稳定复制 `static/icons`
- 页面与音频播放器中曾使用 `/static/exhibit-placeholder.jpg``/static/hall-placeholder.jpg` 作为兜底图,但静态目录里没有对应文件。
- 旧浏览器缓存的 chunk 可能仍请求旧占位图路径,因此即使新代码改用 SVG 兜底,也需要保留兼容文件。
处理方式:
- 更新 `frontend-miniapp/scripts/copy-h5-nav-assets.cjs`,将 `static/icons` 纳入 H5 构建产物复制清单。
- 新代码兜底图改为已存在且会随包发布的:
- `/static/icons/marker-exhibit.svg`
- `/static/icons/marker-hall.svg`
- 同时补充兼容文件:
- `/static/exhibit-placeholder.jpg`
- `/static/hall-placeholder.jpg`
- 重新执行 `pnpm type-check``pnpm build:h5` 并部署 H5 包。
验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/biology.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/evolution.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/dinosaur.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/earth.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/human.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/homeland.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/universe.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/exhibit-placeholder.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/hall-placeholder.jpg
```
期望返回:
```text
HTTP/1.1 200 OK
Content-Type: image/jpeg
```
其中 SVG 兜底图验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/static/icons/marker-exhibit.svg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/marker-hall.svg
```
期望返回:
```text
HTTP/1.1 200 OK
Content-Type: image/svg+xml
```
### 7.6 TTS 语音播放 Mixed Content
现象:
```text
Mixed Content: The page at 'https://guide.whaoyue.com/#/pages/exhibit/detail?id=715805000000000009&tab=explain'
was loaded over HTTPS, but requested an insecure audio file
'http://1.92.206.90:9000/tts-audio/audio/2026/06/30/947228d3e5174f3697f22cceac30075e.mp3'.
This request has been blocked; the content must be served over HTTPS.
详情音频不可播放: 音频加载失败,当前提供图文讲解。
H5 音频播放被拦截或失败: NotSupportedError: Failed to load because no supported source was found.
```
原因:
- 页面运行在 HTTPS 下,但后端语音播放接口返回的 `playUrl` 指向 `http://1.92.206.90:9000/tts-audio/...`
- 浏览器不会把 IP 地址 HTTP 音频自动升级为 HTTPS因此音频请求被 mixed content 策略拦截。
- 服务器 Nginx 已配置 `/tts-audio/` 到 MinIO 的 HTTPS 同源代理,因此核心修复点是前端 URL 归一化,而不是新增代理。
当前 Nginx 代理规则应包含:
```nginx
location ^~ /tts-audio/ {
proxy_pass http://172.17.0.1:9000/tts-audio/;
proxy_set_header Host 172.17.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" always;
add_header Cache-Control "public, max-age=604800" always;
}
```
处理方式:
- 更新前端统一公共 URL 归一化逻辑 `normalizeSameOriginPublicUrl`
- `http://1.92.206.90:9000/museum-assets/...` -> `/museum-assets/...`
- `http://1.92.206.90:9000/tts-audio/...` -> `/tts-audio/...`
- `http://47.120.48.148:19000/nhm/audio/...` -> `/nhm/audio/...`
- 确认 `AudioPlayInfoRepository`、后端讲解数据 adapter、静态导览 adapter 均通过该统一方法处理音频 URL。
- 重新构建并部署 H5 包。
- 扫描线上运行时代码,确认不再包含 `http://1.92.206.90:9000/tts-audio` 或其他 MinIO HTTP 直连。
验证时不要复用已经删除的历史 MinIO 对象。先从当前播放信息接口取得 `playUrl`
```powershell
curl.exe "https://guide.whaoyue.com:4433/app-api/gis/guide/audio/play-info?targetType=STOP&targetId=865546638960193536&lang=zh-CN"
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/tts-audio/audio/2026/07/17/63058096489b4f4aa516fb07fe4a1144.mp3
```
期望返回:
```text
HTTP/1.1 200 OK
Content-Type: audio/mpeg
Accept-Ranges: bytes
```
线上运行时代码扫描:
```bash
grep -R 'http://1\.92\.206\.90:9000/tts-audio\|http://1\.92\.206\.90:9000' \
-n /dmdata/nginx/html/guide/assets /dmdata/nginx/html/guide/index.html || true
```
期望无输出。
## 8. 回滚方式
配置备份目录:
```bash
/dmdata/nginx/backups/20260608002938
/dmdata/nginx/backups/20260608003215
/dmdata/nginx/backups/guide.whaoyue.com.conf-before-port-change-20260722175845
/dmdata/nginx/backups/nginx-server-inspect-before-port-change-20260722175845.json
```
回滚配置示例:
```bash
cp -a /dmdata/nginx/backups/20260608003215/conf.d/* /dmdata/nginx/conf.d/
docker exec nginx-server nginx -t
docker exec nginx-server nginx -s reload
```
如需回滚 2026-07-22 的端口调整,应根据检查快照重建容器为 `80:80``443:443``7080:7080``7081:7081`,同时恢复站点配置中的标准 HTTPS 跳转。执行前必须再次备份当前容器检查信息和站点配置。
如需移除本域名站点:
```bash
rm -f /dmdata/nginx/conf.d/guide.whaoyue.com.conf
docker exec nginx-server nginx -t
docker exec nginx-server nginx -s reload
```
## 9. 运行维护检查清单
日常检查:
```bash
docker ps --filter name=nginx-server
docker exec nginx-server nginx -t
curl -I http://guide.whaoyue.com:8888/
curl -I https://guide.whaoyue.com:4433/
curl -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl -I https://guide.whaoyue.com:4433/h5-sdk
curl -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
curl -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
curl -I https://guide.whaoyue.com:4433/minio/museum-assets/tts-audio/audio/2026/07/17/63058096489b4f4aa516fb07fe4a1144.mp3
curl -I https://guide.whaoyue.com:4433/static/icons/halls/biology.jpg
curl -I https://guide.whaoyue.com:4433/static/exhibit-placeholder.jpg
```
查看日志:
```bash
tail -f /dmdata/nginx/logs/guide-http-access.log
tail -f /dmdata/nginx/logs/guide-https-access.log
tail -f /dmdata/nginx/logs/guide-https-error.log
```
证书检查:
```bash
/root/.acme.sh/acme.sh --list
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -subject -issuer -dates
```
## 10. 验收记录
2026-06-08 验收结果:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| DNS A 记录 | 通过 | `guide.whaoyue.com -> 1.92.206.90` |
| 80 端口公网连通 | 通过 | `Test-NetConnection guide.whaoyue.com -Port 80 = True` |
| 443 端口公网连通 | 通过 | `Test-NetConnection guide.whaoyue.com -Port 443 = True` |
| HTTP 跳转 | 通过 | `HTTP/1.1 301 Moved Permanently` |
| HTTPS 静态页 | 通过 | `HTTP/1.1 200 OK` |
| Nginx 配置 | 通过 | `nginx: configuration file /etc/nginx/nginx.conf test is successful` |
| SSL 证书 | 通过 | `CN = guide.whaoyue.com`, Lets Encrypt |
| 自动续期 | 通过 | `acme.sh` cron 已存在 |
2026-06-26 SDK 后端与模型资源修复验收:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| SDK manifest 同源接口 | 通过 | `/app-api/gis/sdk/maps/1/manifest -> 200 application/json` |
| H5 SDK 基座 | 通过 | `/h5-sdk -> 200 text/html` |
| SDK 脚本 | 通过 | `/static/sgs-map-sdk/index.global.js -> 200 application/javascript` |
| MinIO `/museum-assets/` 模型 | 通过 | `draco_EXTERIOR.glb_1781708354536.glb -> 200 model/gltf-binary` |
| MinIO `/minio/museum-assets/` 模型 | 通过 | `L1_draco.glb -> 200 model/gltf-binary` |
| 混合内容引用 | 通过 | H5 运行时代码无 `1.92.206.90:3001``1.92.206.90:9000` |
2026-06-30 静态图标、占位图与 TTS 音频修复验收:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| 展厅图标资源 | 通过 | `/static/icons/halls/biology.jpg` 等展厅图标 -> `200 image/jpeg` |
| 展品占位图兼容资源 | 通过 | `/static/exhibit-placeholder.jpg -> 200 image/jpeg` |
| 展厅占位图兼容资源 | 通过 | `/static/hall-placeholder.jpg -> 200 image/jpeg` |
| SVG 兜底图资源 | 通过 | `/static/icons/marker-exhibit.svg -> 200 image/svg+xml` |
| TTS 音频 HTTPS 代理 | 通过 | `/tts-audio/audio/2026/06/30/947228d3e5174f3697f22cceac30075e.mp3 -> 200 audio/mpeg` |
| TTS CORS/Range 响应 | 通过 | 响应包含 `Access-Control-Allow-Origin: *``Accept-Ranges: bytes` |
| 混合内容引用 | 通过 | H5 运行时代码无 `http://1.92.206.90:9000/tts-audio`、无 `http://1.92.206.90:9000` |
2026-07-22 公网端口调整验收:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| Docker HTTP 映射 | 通过 | `0.0.0.0:8888 -> 80/tcp` |
| Docker HTTPS 映射 | 通过 | `0.0.0.0:4433 -> 443/tcp` |
| 既有端口保留 | 通过 | `7080/7081 -> 7080/7081` |
| 8888 端口公网连通 | 通过 | `http://guide.whaoyue.com:8888/ -> 301` |
| HTTP 跳转目标 | 通过 | `Location: https://guide.whaoyue.com:4433/` |
| 4433 首页 | 通过 | `https://guide.whaoyue.com:4433/ -> 200` |
| 4433 API 代理 | 通过 | `/app-api/gis/hall/list -> 200 application/json` |
| 域名校验文件 | 通过 | `/gpL0svkeao.txt -> 200 text/plain` |
| 当前语音对象 | 通过 | 播放信息返回的 `/minio/museum-assets/tts-audio/...mp3 -> 200 audio/mpeg` |
| H5 SDK 基座 | 阻塞 | `/h5-sdk -> 502`,不得沿用 2026-06-26 的 200 结论 |
| Nginx 配置 | 通过 | `nginx: configuration file /etc/nginx/nginx.conf test is successful` |
| 证书续期 | 待处理 | 当前 Webroot HTTP-01 不能通过公网 `8888` 完成验证,需恢复 `80` 或迁移 DNS-01 |

View File

@@ -259,7 +259,7 @@ const resume = () => {
}) })
} }
const switchLanguage = async (lang: AudioLanguage) => { const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null) => {
if (pendingLanguage.value) { if (pendingLanguage.value) {
return false return false
} }
@@ -291,6 +291,35 @@ const switchLanguage = async (lang: AudioLanguage) => {
} }
try { try {
if (localAudio?.audioUrl) {
currentSource.value = nextSource
return await play(localAudio, {
source: nextSource,
retryOnError: retryOnError || undefined,
displayMode: preservedMode
})
}
if (localAudio) {
stopAudioElement()
currentSource.value = nextSource
currentAudio.value = {
...localAudio,
audioUrl: '',
duration: 0,
language: lang
}
visible.value = true
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
}
const playInfo = await audioPlayInfoRepository.getPlayInfo({ const playInfo = await audioPlayInfoRepository.getPlayInfo({
targetType: source.targetType, targetType: source.targetType,
targetId: source.targetId, targetId: source.targetId,

View File

@@ -42,11 +42,31 @@ export interface BackendGuideStopInfo {
hasText?: boolean hasText?: boolean
supportedLanguages?: string[] | null supportedLanguages?: string[] | null
audioStatus?: string | null audioStatus?: string | null
languageVariants?: BackendGuideStopLanguageVariant[] | null
reason?: string | null reason?: string | null
linkedExhibitCount?: number | string | null linkedExhibitCount?: number | string | null
isSharedStop?: boolean | null isSharedStop?: boolean | null
} }
export interface BackendGuideStopLanguageVariant {
lang?: string | null
enabled?: boolean | null
playable?: boolean | null
audioStatus?: string | null
playUrl?: string | null
duration?: number | string | null
format?: string | null
audioId?: string | number | null
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
hasText?: boolean | null
textAvailable?: boolean | null
text?: string | null
textLength?: number | string | null
textHash?: string | null
fallback?: boolean | null
reason?: string | null
}
export interface BackendAudioPlayInfo { export interface BackendAudioPlayInfo {
playable?: boolean playable?: boolean
targetType?: string | null targetType?: string | null
@@ -90,6 +110,25 @@ export interface GuideStopLinkedExhibit {
sortOrder?: number sortOrder?: number
} }
export interface GuideStopLanguageVariant {
lang: GuideAudioLanguage
enabled: boolean
playable: boolean
audioStatus: 'READY' | 'MISSING' | string
playUrl?: string
duration?: number
format?: string
audioId?: string
narrationTier?: 'STANDARD' | 'EXTENDED'
hasText: boolean
textAvailable: boolean
text?: string
textLength?: number
textHash?: string
fallback: boolean
reason?: string
}
export interface GuideStopInfo { export interface GuideStopInfo {
available: boolean available: boolean
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
@@ -112,6 +151,7 @@ export interface GuideStopInfo {
hasAudio: boolean hasAudio: boolean
hasText: boolean hasText: boolean
supportedLanguages: GuideAudioLanguage[] supportedLanguages: GuideAudioLanguage[]
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
audioStatus: 'READY' | 'MISSING' | string audioStatus: 'READY' | 'MISSING' | string
reason?: string reason?: string
linkedExhibitCount?: number linkedExhibitCount?: number
@@ -177,16 +217,57 @@ export const normalizeGuideAudioLanguage = (
return 'zh-CN' return 'zh-CN'
} }
const isSupportedGuideAudioLanguage = (value: string | null | undefined) => (
['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(value?.trim().toLowerCase() || '')
)
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => ( const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.from(new Set((languages || []) Array.from(new Set((languages || [])
.map((language) => { .filter(isSupportedGuideAudioLanguage)
const normalized = language?.trim().toLowerCase() .map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[]
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) { )
return null
const normalizeLanguageVariants = (
variants: BackendGuideStopLanguageVariant[] | null | undefined
): Record<GuideAudioLanguage, GuideStopLanguageVariant> => {
const normalizedVariants = {} as Record<GuideAudioLanguage, GuideStopLanguageVariant>
;(variants || []).forEach((variant) => {
if (!isSupportedGuideAudioLanguage(variant.lang)) return
const lang = normalizeGuideAudioLanguage(variant.lang)
const playUrl = normalizeSameOriginPublicUrl(variant.playUrl) || undefined
const playable = variant.playable === true && Boolean(playUrl)
const text = variant.text?.trim() || undefined
const textAvailable = variant.textAvailable === true || Boolean(text)
normalizedVariants[lang] = {
lang,
enabled: variant.enabled === true,
playable,
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
playUrl,
duration: normalizeNumber(variant.duration),
format: variant.format?.trim() || undefined,
audioId: stringifyId(variant.audioId) || undefined,
narrationTier: normalizeNarrationTier(variant.narrationTier),
hasText: variant.hasText === true || textAvailable,
textAvailable,
text,
textLength: normalizeNumber(variant.textLength),
textHash: variant.textHash?.trim() || undefined,
fallback: variant.fallback === true,
reason: variant.reason?.trim() || undefined
} }
return normalizeGuideAudioLanguage(language)
}) })
.filter(Boolean))) as GuideAudioLanguage[]
return normalizedVariants
}
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
(Object.values(variants) as GuideStopLanguageVariant[])
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
.map((variant) => variant.lang)
) )
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => { const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
@@ -255,6 +336,8 @@ export const toGuideStopInfo = (
const coverImageUrl = canUseStopImages const coverImageUrl = canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined ? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined : undefined
const languageVariants = normalizeLanguageVariants(source.languageVariants)
const supportedLanguages = supportedLanguagesFromVariants(languageVariants)
return { return {
available: source.available === true, available: source.available === true,
@@ -277,7 +360,10 @@ export const toGuideStopInfo = (
playTargetId, playTargetId,
hasAudio: source.hasAudio === true, hasAudio: source.hasAudio === true,
hasText: source.hasText === true, hasText: source.hasText === true,
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), supportedLanguages: supportedLanguages.length
? supportedLanguages
: normalizeSupportedLanguages(source.supportedLanguages),
languageVariants,
audioStatus: source.audioStatus || 'MISSING', audioStatus: source.audioStatus || 'MISSING',
reason: source.reason || undefined, reason: source.reason || undefined,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),

View File

@@ -182,10 +182,21 @@ export interface MuseumExhibit {
audioVariants?: Record<string, { audioVariants?: Record<string, {
title?: string title?: string
text?: string text?: string
textAvailable?: boolean
textLength?: number
textHash?: string
audioUrl?: string audioUrl?: string
audioDuration?: number audioDuration?: number
format?: string
audioId?: string
narrationTier?: 'STANDARD' | 'EXTENDED'
hasText?: boolean hasText?: boolean
enabled?: boolean
playable?: boolean
available?: boolean available?: boolean
audioStatus?: string
fallback?: boolean
reason?: string
}> }>
imageStatus?: string imageStatus?: string
imageSource?: string imageSource?: string

View File

@@ -160,7 +160,8 @@ import {
type ExplainDetailPageViewModel type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels' } from '@/view-models/explainViewModels'
import type { import type {
AudioPlayTargetType AudioPlayTargetType,
MuseumExhibit
} from '@/domain/museum' } from '@/domain/museum'
import type { import type {
AudioLanguage AudioLanguage
@@ -193,6 +194,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
} }
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail) const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const detailSource = ref<MuseumExhibit | null>(null)
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle') const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
const detailStateMessage = ref('请检查链接后重试。') const detailStateMessage = ref('请检查链接后重试。')
const globalAudioPlayer = useGlobalAudioPlayer() const globalAudioPlayer = useGlobalAudioPlayer()
@@ -201,7 +203,6 @@ const resolvedHallId = ref('')
const retryingAudio = ref(false) const retryingAudio = ref(false)
const languageSwitchLoading = ref(false) const languageSwitchLoading = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN') const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{ const detailEntryRequest = ref<{
exhibitId: string exhibitId: string
targetType?: AudioPlayTargetType targetType?: AudioPlayTargetType
@@ -217,16 +218,6 @@ const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
'yue-HK': '当前语言暂无讲解词。', 'yue-HK': '当前语言暂无讲解词。',
'en-US': 'English narration text is not available yet.' 'en-US': 'English narration text is not available yet.'
}) })
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。 // 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
const isLocationActionVisible = false const isLocationActionVisible = false
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
@@ -416,63 +407,22 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => {
...detailTextByLanguage.value, ...detailTextByLanguage.value,
[lang]: normalized || fallbackTextForLanguage(lang) [lang]: normalized || fallbackTextForLanguage(lang)
} }
detailTextLoadedByLanguage.value = {
...detailTextLoadedByLanguage.value,
[lang]: true
}
} }
const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({ const hydrateDetailTexts = (source: MuseumExhibit) => {
id: viewModel.id, const nextTexts: Record<AudioLanguage, string> = {
name: viewModel.title, 'zh-CN': fallbackTextForLanguage('zh-CN'),
hallId: viewModel.hallId, 'yue-HK': fallbackTextForLanguage('yue-HK'),
hallName: viewModel.hallName, 'en-US': fallbackTextForLanguage('en-US')
floorId: viewModel.floorId, }
floorLabel: viewModel.floorLabel,
image: viewModel.coverImages[0], ;(['zh-CN', 'yue-HK', 'en-US'] as AudioLanguage[]).forEach((lang) => {
description: viewModel.summary, const variant = source.audioVariants?.[lang]
guideText: viewModel.summary, const text = variant?.text || (source.audioLanguage === lang ? source.guideText : undefined)
audioLanguage: viewModel.audio.language, nextTexts[lang] = text?.trim() || fallbackTextForLanguage(lang)
audioHasText: viewModel.audio.hasText,
playTargetType: viewModel.audio.playTargetType,
playTargetId: viewModel.audio.playTargetId
}) })
const loadFullDetailTextForLanguage = async ( detailTextByLanguage.value = nextTexts
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: true
}
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)
} catch (error) {
console.warn('讲解正文加载失败:', lang, error)
applyDetailText(lang)
} finally {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: false
}
}
} }
const loadExplainDetail = async ( const loadExplainDetail = async (
@@ -485,6 +435,8 @@ const loadExplainDetail = async (
lang lang
}) })
detailSource.value = exhibitData
hydrateDetailTexts(exhibitData)
exhibit.value = toExplainDetailPageViewModel(exhibitData) exhibit.value = toExplainDetailPageViewModel(exhibitData)
const resolvedLanguage = resolveSupportedDetailLanguage(lang) const resolvedLanguage = resolveSupportedDetailLanguage(lang)
selectedAudioLanguage.value = resolvedLanguage selectedAudioLanguage.value = resolvedLanguage
@@ -494,12 +446,13 @@ const loadExplainDetail = async (
} }
if (resolvedLanguage !== lang) { if (resolvedLanguage !== lang) {
const localizedExhibit = explainUseCase.selectExplainDetailLanguage(exhibitData, resolvedLanguage)
detailSource.value = localizedExhibit
exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
selectedAudioLanguage.value = resolvedLanguage
replaceDetailRouteLanguage(resolvedLanguage) replaceDetailRouteLanguage(resolvedLanguage)
await loadExplainDetail(request, resolvedLanguage)
return
} }
void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
detailState.value = 'ready' detailState.value = 'ready'
} }
@@ -618,9 +571,7 @@ const splitDetailTextIntoParagraphs = (text: string): string[] => {
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value)) const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value)) const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
const currentDetailTextLoading = computed(() => ( const currentDetailTextLoading = computed(() => languageSwitchLoading.value)
languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
))
const detailLoadingMessage = computed(() => ( const detailLoadingMessage = computed(() => (
languageSwitchLoading.value languageSwitchLoading.value
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}` ? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
@@ -648,64 +599,42 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang)) || (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
) return ) return
const source = detailSource.value
if (!source) return
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
const request = detailEntryRequest.value const localizedExhibit = explainUseCase.selectExplainDetailLanguage(source, lang)
const requestSequence = ++languageSwitchSequence const localizedViewModel = toExplainDetailPageViewModel(localizedExhibit)
selectedAudioLanguage.value = lang selectedAudioLanguage.value = lang
detailSource.value = localizedExhibit
exhibit.value = localizedViewModel
applyDetailText(lang, localizedExhibit.audioText || localizedExhibit.guideText || localizedViewModel.body)
replaceDetailRouteLanguage(lang) replaceDetailRouteLanguage(lang)
if (localizedExhibit.hallId) {
resolvedHallId.value = localizedExhibit.hallId
}
if (!shouldSyncAudio || globalAudioPlayer.currentSource.value?.lang === lang) return
languageSwitchLoading.value = true languageSwitchLoading.value = true
const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang
? globalAudioPlayer.switchLanguage(lang)
: Promise.resolve(true)
try { try {
if (!request) { const nextAudio = localizedViewModel.audio.url
applyDetailText(lang) ? {
await audioSwitch id: `stop-${localizedViewModel.audio.playTargetType || 'ITEM'}-${localizedViewModel.audio.playTargetId || localizedViewModel.id}-${lang}`,
return name: localizedViewModel.title,
} audioUrl: localizedViewModel.audio.url,
image: heroImage.value,
const exhibitData = await explainUseCase.enterExplainDetail({ duration: localizedViewModel.audio.duration,
...request,
lang
})
if (requestSequence !== languageSwitchSequence) return
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
exhibit.value = nextViewModel
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
await audioSwitch
} catch (error) {
if (requestSequence !== languageSwitchSequence) return
console.warn('讲解语言切换失败:', lang, error)
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
duration: undefined,
language: lang, language: lang,
unavailableReason: '讲解服务暂不可用,请稍后重试' supportedLanguages: supportedDetailLanguages.value
} }
} : null
applyDetailText(lang) await globalAudioPlayer.switchLanguage(lang, nextAudio)
uni.showToast({
title: '讲解语言切换失败,请稍后重试',
icon: 'none'
})
} finally { } finally {
if (requestSequence === languageSwitchSequence) {
languageSwitchLoading.value = false languageSwitchLoading.value = false
} }
} }
}
watch( watch(
() => globalAudioPlayer.currentSource.value, () => globalAudioPlayer.currentSource.value,

View File

@@ -65,6 +65,21 @@ const syncPageTitle = (title: string) => {
if (typeof document !== 'undefined') document.title = title if (typeof document !== 'undefined') document.title = title
} }
const decodeRouteParam = (value: string) => {
let decoded = value
// H5 hash routes embedded in web-view can add multiple URL-encoding layers.
for (let index = 0; index < 8; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
return decoded
}
const loadPage = async (pageNo: number, replace: boolean) => { const loadPage = async (pageNo: number, replace: boolean) => {
const hallId = selectedExplainHallId.value const hallId = selectedExplainHallId.value
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
@@ -115,8 +130,8 @@ const loadNextPage = () => void loadPage(nextPageNo.value, false)
onLoad((options: Record<string, string | string[] | undefined> = {}) => { onLoad((options: Record<string, string | string[] | undefined> = {}) => {
const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
selectedExplainHallId.value = first(options.hallId) || '' selectedExplainHallId.value = first(options.hallId) || ''
selectedExplainHallName.value = first(options.hallName) || '讲解对象' selectedExplainHallName.value = decodeRouteParam(first(options.hallName) || '讲解对象')
syncPageTitle('讲解对象') syncPageTitle(selectedExplainHallName.value)
if (!selectedExplainHallId.value) { if (!selectedExplainHallId.value) {
explainError.value = '缺少展厅参数,请返回展厅列表后重试' explainError.value = '缺少展厅参数,请返回展厅列表后重试'
return return

View File

@@ -72,6 +72,7 @@ const reasonMessageMap: Record<string, string> = {
NO_GUIDE_STOP: '该展品暂未配置语音讲解', NO_GUIDE_STOP: '该展品暂未配置语音讲解',
NO_GUIDE_CONTENT: '该目标暂无讲解内容', NO_GUIDE_CONTENT: '该目标暂无讲解内容',
NO_TEXT: '当前语言暂无讲解词', NO_TEXT: '当前语言暂无讲解词',
LANGUAGE_DISABLED: '该语言暂未开放',
UNSUPPORTED_LANGUAGE: '不支持该语言', UNSUPPORTED_LANGUAGE: '不支持该语言',
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放', UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解', TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',

View File

@@ -183,21 +183,29 @@ export class ExplainUseCase {
} }
} }
const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl)
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
const guideText = variant.text
|| (language === 'yue-HK' ? mandarinText : undefined)
|| `${this.languageLabel(language)}讲解词暂未配置。`
return { return {
...exhibit, ...exhibit,
guideTitle: variant.title || exhibit.guideTitle, guideTitle: variant.title || exhibit.guideTitle,
guideText: language === 'yue-HK' guideText,
? mandarinText || '当前讲解词暂未配置。' audioUrl: playable ? variant.audioUrl : undefined,
: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`,
audioUrl: variant.audioUrl,
audioDuration: variant.audioDuration, audioDuration: variant.audioDuration,
audioLanguage: language, audioLanguage: language,
audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true, audioHasText: textAvailable,
audioAvailable: variant.available === true && Boolean(variant.audioUrl), audioText: variant.text || undefined,
audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING', audioTextLength: variant.textLength,
audioUnavailableReason: variant.available && variant.audioUrl audioTextHash: variant.textHash,
audioNarrationTier: variant.narrationTier,
audioAvailable: playable,
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
audioUnavailableReason: playable
? undefined ? undefined
: `当前讲解暂无${this.languageLabel(language)}音频`, : audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
supportedLanguages supportedLanguages
} }
} }
@@ -213,7 +221,27 @@ export class ExplainUseCase {
: undefined : undefined
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。' const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo) const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
const audioAvailable = stopInfo.audioStatus === 'READY' const audioVariants = Object.fromEntries(Object.values(stopInfo.languageVariants).map((variant) => [variant.lang, {
title: stopInfo.title,
text: variant.text,
textAvailable: variant.textAvailable,
textLength: variant.textLength,
textHash: variant.textHash,
audioUrl: variant.playUrl,
audioDuration: variant.duration,
format: variant.format,
audioId: variant.audioId,
narrationTier: variant.narrationTier,
hasText: variant.hasText,
enabled: variant.enabled,
playable: variant.playable,
available: variant.playable,
audioStatus: variant.audioStatus,
fallback: variant.fallback,
reason: variant.reason
}]))
const currentVariant = audioVariants[stopInfo.lang]
const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl)
return { return {
...(fallback || {}), ...(fallback || {}),
@@ -235,21 +263,22 @@ export class ExplainUseCase {
size: fallback?.size, size: fallback?.size,
tags: fallback?.tags, tags: fallback?.tags,
guideTitle: stopInfo.title || fallback?.guideTitle, guideTitle: stopInfo.title || fallback?.guideTitle,
guideText: stopInfo.description || fallback?.guideText || fallback?.description, guideText: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description,
audioUrl: undefined, audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined,
audioDuration: undefined, audioDuration: currentVariant?.audioDuration,
audioLanguage: stopInfo.lang, audioLanguage: stopInfo.lang,
audioHasText: stopInfo.hasText, audioHasText: currentVariant?.textAvailable || stopInfo.hasText,
audioText: undefined, audioText: currentVariant?.text,
audioTextLength: undefined, audioTextLength: currentVariant?.textLength,
audioTextHash: undefined, audioTextHash: currentVariant?.textHash,
audioNarrationTier: undefined, audioNarrationTier: currentVariant?.narrationTier,
audioUnavailableReason: audioAvailable audioUnavailableReason: audioAvailable
? undefined ? undefined
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)), : audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
audioAvailable, audioAvailable,
audioStatus: stopInfo.audioStatus, audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus,
supportedLanguages: stopInfo.supportedLanguages, supportedLanguages: stopInfo.supportedLanguages,
audioVariants,
imageStatus: stopInfo.imageStatus, imageStatus: stopInfo.imageStatus,
imageSource: stopInfo.imageSource, imageSource: stopInfo.imageSource,
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [], galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
@@ -434,6 +463,10 @@ export class ExplainUseCase {
throw stopInfoResult.error || new Error('讲解详情加载失败') throw stopInfoResult.error || new Error('讲解详情加载失败')
} }
selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) {
return this.applyLanguageVariant(exhibit, language)
}
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> { async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
const targetType = exhibit.playTargetType || 'ITEM' const targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id const targetId = exhibit.playTargetId || exhibit.id

View File

@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
onHideHandler: null as null | (() => void), onHideHandler: null as null | (() => void),
onUnloadHandler: null as null | (() => void), onUnloadHandler: null as null | (() => void),
enterExplainDetail: vi.fn(), enterExplainDetail: vi.fn(),
selectExplainDetailLanguage: vi.fn(),
loadExplainDetailText: vi.fn(), loadExplainDetailText: vi.fn(),
selectAudioForExplainDetail: vi.fn(), selectAudioForExplainDetail: vi.fn(),
play: vi.fn(), play: vi.fn(),
@@ -43,6 +44,7 @@ vi.mock('@dcloudio/uni-app', () => ({
vi.mock('@/usecases/explainUseCase', () => ({ vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: { explainUseCase: {
enterExplainDetail: mocks.enterExplainDetail, enterExplainDetail: mocks.enterExplainDetail,
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
loadExplainDetailText: mocks.loadExplainDetailText, loadExplainDetailText: mocks.loadExplainDetailText,
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
} }
@@ -110,6 +112,10 @@ describe('讲解详情音频优先布局', () => {
audioState.currentTime.value = 0 audioState.currentTime.value = 0
audioState.duration.value = 0 audioState.duration.value = 0
mocks.enterExplainDetail.mockResolvedValue(exhibit) mocks.enterExplainDetail.mockResolvedValue(exhibit)
mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({
...source,
audioLanguage: lang
}))
mocks.loadExplainDetailText.mockResolvedValue({ available: false }) mocks.loadExplainDetailText.mockResolvedValue({ available: false })
mocks.selectAudioForExplainDetail.mockResolvedValue({ mocks.selectAudioForExplainDetail.mockResolvedValue({
playable: true, playable: true,
@@ -191,7 +197,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-language-bar').exists()).toBe(false) expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
}) })
it('深链语言不受支持时回退至中文并规范化地址', async () => { it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({ mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
...exhibit, ...exhibit,
audioLanguage: request.lang, audioLanguage: request.lang,
@@ -205,8 +211,9 @@ describe('讲解详情音频优先布局', () => {
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises() await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' })) expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(2, expect.objectContaining({ lang: 'zh-CN' })) expect(mocks.enterExplainDetail).toHaveBeenCalledWith(expect.objectContaining({ lang: 'en-US' }))
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'zh-CN')
expect(wrapper.get('.language-option').classes()).toContain('active') expect(wrapper.get('.language-option').classes()).toContain('active')
expect(wrapper.get('.language-option-text').text()).toBe('中文') expect(wrapper.get('.language-option-text').text()).toBe('中文')
expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN')) expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN'))
@@ -244,6 +251,34 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined() expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
}) })
it('切换语言时使用初次详情响应中的变体,不重复请求详情或正文', async () => {
const localizedExhibit = {
...exhibit,
guideText: '粤语通道使用普通话文案。',
audioUrl: '/assets/astrolabe-yue.mp3',
audioDuration: 83,
audioLanguage: 'yue-HK',
audioStatus: 'READY',
audioHasText: true,
supportedLanguages: ['zh-CN', 'yue-HK', 'en-US']
}
mocks.selectExplainDetailLanguage.mockReturnValue(localizedExhibit)
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
await wrapper.findAll('.language-option').find((node) => node.text() === '粤语')?.trigger('tap')
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.loadExplainDetailText).not.toHaveBeenCalled()
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK')
expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案')
expect(wrapper.get('.detail-audio-subtitle').text()).toContain('粤语')
})
it('无音频时禁用播放按钮并显示原因', async () => { it('无音频时禁用播放按钮并显示原因', async () => {
mocks.enterExplainDetail.mockResolvedValue({ mocks.enterExplainDetail.mockResolvedValue({
...exhibit, ...exhibit,

View File

@@ -98,18 +98,21 @@ describe('讲解对象列表返回', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('keeps the selected hall context when an object payload omits hall fields', async () => { it('uses the decoded hall name as the page title and detail context', async () => {
const wrapper = mountGuideStopList() const wrapper = mountGuideStopList()
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '恐龙厅' }) testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '%25E6%25BC%2594%25E5%258C%2596%25E5%258E%2585' })
await flushPromises() await flushPromises()
expect(uni.setNavigationBarTitle).toHaveBeenCalledWith({ title: '演化厅' })
expect(document.title).toBe('演化厅')
await wrapper.get('[data-testid="detail-click"]').trigger('click') await wrapper.get('[data-testid="detail-click"]').trigger('click')
const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url || '' const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url || ''
const params = new URLSearchParams(url.split('?')[1]) const params = new URLSearchParams(url.split('?')[1])
expect(url.split('?')[0]).toBe('/pages/exhibit/detail') expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
expect(params.get('hallId')).toBe('hall-1') expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅') expect(params.get('hallName')).toBe('演化厅')
expect(params.get('targetType')).toBe('STOP') expect(params.get('targetType')).toBe('STOP')
expect(params.get('targetId')).toBe('target-1') expect(params.get('targetId')).toBe('target-1')
wrapper.unmount() wrapper.unmount()

View File

@@ -25,6 +25,7 @@ const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo =
hasAudio: false, hasAudio: false,
hasText: false, hasText: false,
supportedLanguages: [], supportedLanguages: [],
languageVariants: {},
audioStatus: 'MISSING', audioStatus: 'MISSING',
...overrides ...overrides
}) })
@@ -99,4 +100,60 @@ describe('ExplainUseCase stop image policy', () => {
expect(detail.image).toBe('/guide-stop.jpg') expect(detail.image).toBe('/guide-stop.jpg')
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg']) expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
}) })
it('uses stop-info language variants locally without requesting play-info or text-info', async () => {
const audio = {
getStopInfo: vi.fn().mockResolvedValue(createStopInfo({
hasAudio: true,
hasText: true,
supportedLanguages: ['zh-CN', 'yue-HK'],
languageVariants: {
'zh-CN': {
lang: 'zh-CN',
enabled: true,
playable: true,
playUrl: '/audio/zh.mp3',
duration: 50,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
fallback: false,
audioStatus: 'READY'
},
'yue-HK': {
lang: 'yue-HK',
enabled: true,
playable: true,
playUrl: '/audio/yue.mp3',
duration: 52,
hasText: true,
textAvailable: true,
text: '粤语通道使用普通话文案',
fallback: false,
audioStatus: 'READY'
}
} as GuideStopInfo['languageVariants']
})),
getPlayInfo: vi.fn(),
getTextInfo: vi.fn()
} as unknown as AudioPlayInfoRepository
const explain = {
getExhibitById: vi.fn(),
listExplainExhibits: vi.fn()
} as unknown as ExplainRepository
const useCase = new ExplainUseCase(explain, audio)
const detail = await useCase.enterExplainDetail({
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN'
})
const cantonese = useCase.selectExplainDetailLanguage(detail, 'yue-HK')
expect(detail.guideText).toBe('普通话讲解词')
expect(detail.audioUrl).toBe('/audio/zh.mp3')
expect(cantonese.guideText).toBe('粤语通道使用普通话文案')
expect(cantonese.audioUrl).toBe('/audio/yue.mp3')
expect(cantonese.audioDuration).toBe(52)
expect((audio as any).getPlayInfo).not.toHaveBeenCalled()
expect((audio as any).getTextInfo).not.toHaveBeenCalled()
})
}) })

View File

@@ -35,7 +35,36 @@ describe('全局讲解播放器语言切换', () => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => { it('详情页提供语言变体播放地址时直接切换,不请求 play-info', async () => {
await player.play({
id: 'mandarin-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/mandarin.mp3',
language: 'zh-CN'
}, {
source: {
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'zh-CN'
}
})
await expect(player.switchLanguage('yue-HK', {
id: 'cantonese-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/cantonese.mp3',
language: 'yue-HK'
})).resolves.toBe(true)
expect(repositoryMocks.getPlayInfo).not.toHaveBeenCalled()
expect(player.currentSource.value?.lang).toBe('yue-HK')
expect(player.currentAudio.value).toEqual(expect.objectContaining({
language: 'yue-HK',
audioUrl: '/museum-assets/audio/cantonese.mp3'
}))
})
it('未提供详情语言变体时保留 play-info 刷新回退', async () => {
repositoryMocks.getPlayInfo.mockResolvedValue({ repositoryMocks.getPlayInfo.mockResolvedValue({
playable: true, playable: true,
targetType: 'STOP', targetType: 'STOP',

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { toGuideStopInfo } 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',
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'
}
]
}, {
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({})
})
})