Compare commits
5 Commits
codex/impo
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 337446f33c | |||
| bb6be9a04f | |||
|
|
00585a7b48 | ||
| 2edb6bd9f2 | |||
| c287ef3a2a |
46
doc/h5-guide-api-audit-2026-08-04.md
Normal file
46
doc/h5-guide-api-audit-2026-08-04.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# H5 讲解接口只读审计
|
||||||
|
|
||||||
|
- 审计时间:2026-08-04(Asia/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`。
|
||||||
613
docs/deployment/guide-test-server-nginx-ssl.md
Normal file
613
docs/deployment/guide-test-server-nginx-ssl.md
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
# 测试服务器导览 H5 部署手册
|
||||||
|
|
||||||
|
最后更新:2026-09-15
|
||||||
|
|
||||||
|
## 1. 部署目标
|
||||||
|
|
||||||
|
- 站点:`guide.whaoyue.com`
|
||||||
|
- 服务器公网 IP:`124.220.83.186`
|
||||||
|
- SSH 用户:`root`
|
||||||
|
- 操作系统:Ubuntu 22.04.5 LTS
|
||||||
|
- HTTPS 入口:`https://guide.whaoyue.com:4433/`
|
||||||
|
- HTTP/IP 测试入口:`http://124.220.83.186:8888/`
|
||||||
|
- 部署对象:`frontend-miniapp` H5 构建产物
|
||||||
|
- Nginx 容器:`sgs-nature-nginx`
|
||||||
|
|
||||||
|
`8888` 直接提供明文 HTTP,不再跳转到 HTTPS,并接受域名、公网 IP 和其他 Host。HTTP 仅用于测试;请求内容、登录凭据和 Token 均不会被加密,不得作为生产入口。
|
||||||
|
|
||||||
|
Nginx 源站也支持 `http://guide.whaoyue.com:8888/`,但截至 2026-09-15,腾讯侧会在请求到达 Nginx 前将该域名 HTTP 请求重定向到 DNSPod `webblock` 页面。因此公网 HTTP 验收应使用 IP 入口;该外部拦截不能通过本机 Nginx 配置消除。
|
||||||
|
|
||||||
|
域名 DNS A 记录应指向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
guide.whaoyue.com -> 124.220.83.186
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 当前部署拓扑
|
||||||
|
|
||||||
|
```text
|
||||||
|
公网用户
|
||||||
|
-> 124.220.83.186:8888(HTTP)/ guide.whaoyue.com:4433(HTTPS)
|
||||||
|
-> sgs-nature-nginx Docker 容器
|
||||||
|
-> 容器使用 host 网络
|
||||||
|
-> /usr/share/nginx/html/guide
|
||||||
|
-> 宿主机 /data/sgs-nature/web/guide
|
||||||
|
```
|
||||||
|
|
||||||
|
`sgs-nature-nginx` 当前不是 Docker Compose、Docker Stack、systemd 或 1Panel 项目管理的容器,而是手动创建并启动的 Docker 容器。
|
||||||
|
|
||||||
|
容器实际参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
镜像:nginx:latest
|
||||||
|
Nginx:1.31.3
|
||||||
|
网络:host
|
||||||
|
重启策略:unless-stopped
|
||||||
|
启动命令:nginx -g 'daemon off;'
|
||||||
|
```
|
||||||
|
|
||||||
|
当前容器挂载:
|
||||||
|
|
||||||
|
| 宿主机路径 | 容器路径 | 权限 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/data/sgs-nature/deploy/nginx.conf` | `/etc/nginx/conf.d/default.conf` | 只读 | Nginx 配置 |
|
||||||
|
| `/data/sgs-nature/web` | `/usr/share/nginx/html` | 只读 | 静态站点和资源 |
|
||||||
|
|
||||||
|
由于容器使用 `host` 网络,Docker 没有 `-p` 端口映射。Nginx 配置中的 `listen 8888` 和 `listen 4433` 就是宿主机的监听端口。`8888` 直接提供 HTTP,`4433` 提供 HTTPS。
|
||||||
|
|
||||||
|
不要操作或重启服务器上另一个名为 `nginx` 的容器。该容器占用公网标准端口 `80/443`,与本导览容器职责不同。因此导览 HTTP 入口必须显式携带 `:8888`,不能使用无端口的 `http://124.220.83.186/`。
|
||||||
|
|
||||||
|
## 3. 服务器目录
|
||||||
|
|
||||||
|
导览站点目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/sgs-nature/web/guide/
|
||||||
|
```
|
||||||
|
|
||||||
|
容器内对应目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/usr/share/nginx/html/guide/
|
||||||
|
```
|
||||||
|
|
||||||
|
证书目录按当前部署方案放在已有 Web 挂载目录内:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem
|
||||||
|
/data/sgs-nature/web/ssl/guide.whaoyue.com/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
容器内证书路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/usr/share/nginx/html/ssl/guide.whaoyue.com/fullchain.pem
|
||||||
|
/usr/share/nginx/html/ssl/guide.whaoyue.com/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
证书目录位于 Web 挂载目录下,因此 Nginx 配置必须拒绝公网访问:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /ssl/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
证书权限建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fullchain.pem:0644
|
||||||
|
privkey.pem:0600
|
||||||
|
```
|
||||||
|
|
||||||
|
相关部署和备份文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/sgs-nature/deploy/nginx.conf
|
||||||
|
/data/sgs-nature/backup/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 本地构建
|
||||||
|
|
||||||
|
项目根目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
E:\MyWork\深圳国际艺术馆\museum-guide\museum-guide-v4.0\frontend-miniapp
|
||||||
|
```
|
||||||
|
|
||||||
|
环境要求:
|
||||||
|
|
||||||
|
- Node.js `>=20 <25`
|
||||||
|
- pnpm 9
|
||||||
|
|
||||||
|
安装依赖:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
测试环境默认配置文件为 `.env.test`,关键配置如下:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
VITE_APP_PUBLIC_BASE=/
|
||||||
|
VITE_GUIDE_DATA_SOURCE_MODE=sdk
|
||||||
|
VITE_EXPLAIN_CONTENT_SOURCE_MODE=remote
|
||||||
|
VITE_DATA_SOURCE_MODE=sdk
|
||||||
|
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
|
||||||
|
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
||||||
|
VITE_API_BASE_URL=/app-api
|
||||||
|
VITE_AUDIO_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?v=2.5.0
|
||||||
|
VITE_SGS_H5_ENGINE_URL=/engine/index.html
|
||||||
|
VITE_SGS_SDK_ORIGIN=
|
||||||
|
```
|
||||||
|
|
||||||
|
由于站点通过域名根路径提供服务,`VITE_APP_PUBLIC_BASE` 必须为 `/`,不能设置为 `/guide/`。`guide` 是服务器上的目录名,不是 URL 路径。
|
||||||
|
|
||||||
|
执行质量检查和测试构建:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm type-check
|
||||||
|
pnpm lint
|
||||||
|
pnpm build:test:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm build:test:h5` 会生成 H5 产物,并复制以下资源:
|
||||||
|
|
||||||
|
- `static/nav-assets`
|
||||||
|
- `static/guide-data`
|
||||||
|
- `static/sgs-map-sdk`
|
||||||
|
- `static/three`
|
||||||
|
- `static/icons`
|
||||||
|
- `static/explain`
|
||||||
|
- `static/Fonts`
|
||||||
|
- `engine`
|
||||||
|
- 讲解图片、占位图片等补充资源
|
||||||
|
|
||||||
|
构建目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/build/h5/
|
||||||
|
```
|
||||||
|
|
||||||
|
生产发布需要真实腾讯地图 Web Key 时,使用生产构建:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:VITE_TENCENT_MAP_KEY = '<真实腾讯地图 Web Key>'
|
||||||
|
pnpm build:h5
|
||||||
|
Remove-Item Env:VITE_TENCENT_MAP_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
不要将真实 Key、密码、Token 或其他密钥提交到仓库。
|
||||||
|
|
||||||
|
构建后建议检查:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-Path dist\build\h5\index.html
|
||||||
|
Get-ChildItem dist\build\h5 -Recurse -File | Measure-Object Length -Sum
|
||||||
|
rg -n -S --glob '!*.map' '1\.92\.206\.90|guide\.whaoyue\.com|http://124\.220\.83\.186:9000' dist\build\h5
|
||||||
|
```
|
||||||
|
|
||||||
|
最后一个命令不应出现旧服务器地址或不应使用的绝对 HTTP 资源地址。
|
||||||
|
|
||||||
|
## 5. 打包和上传
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType Directory -Force -Path '.tmp' | Out-Null
|
||||||
|
$archive = '.tmp\museum-guide-h5.tar.gz'
|
||||||
|
if (Test-Path -LiteralPath $archive) {
|
||||||
|
Remove-Item -LiteralPath $archive -Force
|
||||||
|
}
|
||||||
|
tar -C 'dist\build\h5' -czf $archive .
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH 主机别名为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
museum-guide-test
|
||||||
|
```
|
||||||
|
|
||||||
|
该别名位于本机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Users\Administrator\.ssh\config
|
||||||
|
```
|
||||||
|
|
||||||
|
上传构建包:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scp '.tmp\museum-guide-h5.tar.gz' 'museum-guide-test:/tmp/museum-guide-h5.tar.gz'
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把密码写入 SSH 配置、命令脚本或部署文档。
|
||||||
|
|
||||||
|
## 6. 证书
|
||||||
|
|
||||||
|
当前使用的证书为 `guide.whaoyue.com` 的 Let’s Encrypt 证书,记录的有效期为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Not Before: 2026-06-07 15:33:10 GMT
|
||||||
|
Not After: 2026-09-05 15:33:09 GMT
|
||||||
|
```
|
||||||
|
|
||||||
|
截至 2026-09-15,该证书已经过期。`4433` 仍能建立忽略证书校验的 HTTPS 连接,但浏览器会显示安全警告;应尽快完成证书续期。开放 `8888` HTTP 仅用于测试,不替代证书续期。
|
||||||
|
|
||||||
|
查看证书:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 \
|
||||||
|
-in /data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem \
|
||||||
|
-noout -subject -issuer -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
证书续期注意事项:
|
||||||
|
|
||||||
|
- 新服务器目前没有发现已配置的 `acme.sh` 或 `certbot` 续期任务。
|
||||||
|
- Let’s Encrypt HTTP-01 验证固定使用公网 `80`,不会访问 `8888`。
|
||||||
|
- 公网 `80/443` 已被服务器上另一个 `nginx` 容器占用。
|
||||||
|
- 证书到期前必须配置 DNS-01,或者制定临时借用公网 `80` 完成验证的方案。
|
||||||
|
- 续期后需要把新证书复制到上述目录,并执行 Nginx 配置检查和 reload。
|
||||||
|
|
||||||
|
续期后的最低操作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec sgs-nature-nginx nginx -t
|
||||||
|
docker exec sgs-nature-nginx nginx -s reload
|
||||||
|
openssl x509 \
|
||||||
|
-in /data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem \
|
||||||
|
-noout -subject -issuer -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Nginx 配置
|
||||||
|
|
||||||
|
配置文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/sgs-nature/deploy/nginx.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
当前已有 `18101` server,服务 `/dp/`、`/gl/`、`/map/` 及自然馆测试环境 API。新增导览站点时不得删除或覆盖该 server。
|
||||||
|
|
||||||
|
导览 HTTP 与 HTTPS 由同一个 server 块提供,以避免两套静态资源和反向代理规则发生漂移。核心配置:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
# 测试环境明文 HTTP;公网 80 仍由另一 nginx 容器占用。
|
||||||
|
listen 8888 default_server;
|
||||||
|
listen 4433 ssl;
|
||||||
|
server_name guide.whaoyue.com 124.220.83.186 _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html/guide;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
ssl_certificate /usr/share/nginx/html/ssl/guide.whaoyue.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /usr/share/nginx/html/ssl/guide.whaoyue.com/privkey.pem;
|
||||||
|
|
||||||
|
location ^~ /ssl/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /static/nav-assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /engine/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /static/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app-api/ {
|
||||||
|
proxy_pass http://127.0.0.1:48100;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /minio/ {
|
||||||
|
proxy_pass http://127.0.0.1:9000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host 127.0.0.1:9000;
|
||||||
|
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_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /museum-assets/ {
|
||||||
|
proxy_pass http://127.0.0.1:9000/museum-assets/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host 127.0.0.1:9000;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`/static/nav-assets/` 必须使用 `try_files $uri =404`,避免缺失 JSON、GLB 或其他模型资源时错误返回 SPA 的 `index.html`。
|
||||||
|
|
||||||
|
修改配置前备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
cp -p /data/sgs-nature/deploy/nginx.conf \
|
||||||
|
"/data/sgs-nature/backup/nginx-before-guide-$TS.conf"
|
||||||
|
```
|
||||||
|
|
||||||
|
配置文件以单文件 bind mount 方式挂载。修改时必须保留宿主机文件 inode,例如原地编辑或使用 `cat candidate.conf > "$CONFIG"`;不要使用 `mv`、`install` 或其他原子替换方式覆盖路径。替换 inode 后,容器会继续看到旧文件,普通 reload 不会载入新配置。
|
||||||
|
|
||||||
|
修改后先确认宿主机和容器内文件一致,再检查并 reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONFIG=/data/sgs-nature/deploy/nginx.conf
|
||||||
|
sha256sum "$CONFIG"
|
||||||
|
docker exec sgs-nature-nginx \
|
||||||
|
sha256sum /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
docker exec sgs-nature-nginx nginx -t
|
||||||
|
docker exec sgs-nature-nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
配置检查失败时不得 reload。应使用备份原地恢复配置,重新执行 `nginx -t`,确认通过后再 reload。如果已经替换了 inode,应先用独立临时容器对宿主机新配置执行 `nginx -t`,然后只重启 `sgs-nature-nginx` 使其重新建立挂载;仍然不要操作另一个名为 `nginx` 的容器。
|
||||||
|
|
||||||
|
本部署方式不需要:
|
||||||
|
|
||||||
|
- 新增 Docker Compose 文件
|
||||||
|
- 新增 Nginx systemd 服务
|
||||||
|
- 新增 Docker 端口映射
|
||||||
|
- 重启服务器原有 `nginx` 容器
|
||||||
|
|
||||||
|
## 8. 发布站点文件
|
||||||
|
|
||||||
|
上传压缩包后,在服务器上执行。发布前先备份当前 `guide` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
SITE=/data/sgs-nature/web/guide
|
||||||
|
ARCHIVE=/tmp/museum-guide-h5.tar.gz
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
BACKUP=/data/sgs-nature/backup
|
||||||
|
|
||||||
|
mkdir -p "$SITE" "$BACKUP"
|
||||||
|
if [ -f "$SITE/index.html" ]; then
|
||||||
|
tar -C "$SITE" -czf "$BACKUP/guide-before-deploy-$TS.tar.gz" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
test -f "$ARCHIVE"
|
||||||
|
find "$SITE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||||
|
tar -C "$SITE" -xzf "$ARCHIVE"
|
||||||
|
mkdir -p /data/sgs-nature/web/ssl/guide.whaoyue.com
|
||||||
|
|
||||||
|
printf 'backup=%s\n' "$BACKUP/guide-before-deploy-$TS.tar.gz"
|
||||||
|
printf 'files=%s\n' "$(find "$SITE" -type f | wc -l)"
|
||||||
|
```
|
||||||
|
|
||||||
|
证书目录不属于 H5 构建包,不能在清理站点目录时误删。证书位于同级的 `/data/sgs-nature/web/ssl/`,不在 `/data/sgs-nature/web/guide/` 内。
|
||||||
|
|
||||||
|
如果证书需要更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -o root -g root -m 0644 fullchain.pem \
|
||||||
|
/data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem
|
||||||
|
install -o root -g root -m 0600 privkey.pem \
|
||||||
|
/data/sgs-nature/web/ssl/guide.whaoyue.com/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 部署后验证
|
||||||
|
|
||||||
|
### 9.1 DNS 和端口
|
||||||
|
|
||||||
|
在 Windows PowerShell 中:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Resolve-DnsName guide.whaoyue.com -Type A
|
||||||
|
Test-NetConnection 124.220.83.186 -Port 8888
|
||||||
|
Test-NetConnection guide.whaoyue.com -Port 4433
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS A 记录应为 `124.220.83.186`。
|
||||||
|
|
||||||
|
### 9.2 HTTP/IP 和 HTTPS
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I --max-time 30 http://124.220.83.186:8888/
|
||||||
|
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/
|
||||||
|
```
|
||||||
|
|
||||||
|
期望:
|
||||||
|
|
||||||
|
```text
|
||||||
|
124.220.83.186:8888:200 OK,不跳转
|
||||||
|
guide.whaoyue.com:4433:200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
公网直接访问 `http://guide.whaoyue.com:8888/` 可能收到腾讯 DNSPod `webblock` 的 `302`,这不代表源站 Nginx 未启用 HTTP。可在服务器本机携带域名 Host 验证源站:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I -H 'Host: guide.whaoyue.com' http://127.0.0.1:8888/
|
||||||
|
```
|
||||||
|
|
||||||
|
源站期望返回 `200 OK`。
|
||||||
|
|
||||||
|
检查证书:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmd /c "echo.| openssl s_client -connect guide.whaoyue.com:4433 -servername guide.whaoyue.com 2>NUL | openssl x509 -noout -subject -issuer -dates"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 关键静态资源
|
||||||
|
|
||||||
|
不要固定使用旧版本的导航资源目录名。先从构建产物或服务器目录查找当前 Manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find /data/sgs-nature/web/guide/static/nav-assets \
|
||||||
|
-name app_nav_manifest.json -print
|
||||||
|
```
|
||||||
|
|
||||||
|
假设当前资源包目录为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/static/nav-assets/<当前资源包目录>/
|
||||||
|
```
|
||||||
|
|
||||||
|
验证 Manifest、楼层数据和 GLB:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/static/guide-data/manifest.json
|
||||||
|
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
|
||||||
|
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/engine/index.html
|
||||||
|
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/static/icons/marker-exhibit.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
导航资源应至少验证:
|
||||||
|
|
||||||
|
- `app_nav_manifest.json`:`200`,JSON
|
||||||
|
- 一个实际楼层 JSON:`200`,JSON
|
||||||
|
- 一个实际 GLB:`200`,`model/gltf-binary`
|
||||||
|
- 不存在的 GLB:`404`
|
||||||
|
|
||||||
|
缺失的导航资源不能返回 `index.html`。
|
||||||
|
|
||||||
|
### 9.4 API 和原有服务
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I --max-time 30 http://124.220.83.186:8888/app-api/gis/sdk/maps/1/manifest
|
||||||
|
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
|
||||||
|
curl.exe -I --max-time 30 http://124.220.83.186:18101/dp/
|
||||||
|
```
|
||||||
|
|
||||||
|
期望:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 与 HTTPS /app-api/gis/sdk/maps/1/manifest:200 application/json
|
||||||
|
18101 /dp/:200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 私钥保护
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -sS -o NUL -w "%{http_code}\n" `
|
||||||
|
http://124.220.83.186:8888/ssl/guide.whaoyue.com/privkey.pem
|
||||||
|
curl.exe -k -sS -o NUL -w "%{http_code}\n" `
|
||||||
|
https://guide.whaoyue.com:4433/ssl/guide.whaoyue.com/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
两个入口都应返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
404
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.6 浏览器验收
|
||||||
|
|
||||||
|
分别打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://124.220.83.186:8888/
|
||||||
|
https://guide.whaoyue.com:4433/
|
||||||
|
```
|
||||||
|
|
||||||
|
证书续期前,第二个地址会显示证书过期警告。
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- 首页正常加载
|
||||||
|
- 馆外 2D 地图和馆内 3D 入口显示正常
|
||||||
|
- 进入馆内 3D 后,实际楼层模型可以加载
|
||||||
|
- 楼层切换和 POI 数据正常
|
||||||
|
- 讲解列表、展厅、讲解点和详情页正常
|
||||||
|
- 图片、模型、音频没有 Mixed Content
|
||||||
|
- 浏览器控制台没有关键资源 404
|
||||||
|
- 不存在的模型资源不会返回 HTML
|
||||||
|
- `/dp/`、`/gl/`、`/map/` 原有服务未受影响
|
||||||
|
|
||||||
|
## 10. 回滚
|
||||||
|
|
||||||
|
### 10.1 回滚前端文件
|
||||||
|
|
||||||
|
部署时会在以下目录生成备份:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/sgs-nature/backup/guide-before-deploy-YYYYMMDDHHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
SITE=/data/sgs-nature/web/guide
|
||||||
|
BACKUP=/data/sgs-nature/backup/guide-before-deploy-YYYYMMDDHHMMSS.tar.gz
|
||||||
|
|
||||||
|
test -f "$BACKUP"
|
||||||
|
find "$SITE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||||
|
tar -C "$SITE" -xzf "$BACKUP"
|
||||||
|
docker exec sgs-nature-nginx nginx -t
|
||||||
|
docker exec sgs-nature-nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 回滚 Nginx 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
CONFIG=/data/sgs-nature/deploy/nginx.conf
|
||||||
|
BACKUP=/data/sgs-nature/backup/nginx-before-guide-YYYYMMDDHHMMSS.conf
|
||||||
|
|
||||||
|
test -f "$BACKUP"
|
||||||
|
cat "$BACKUP" > "$CONFIG"
|
||||||
|
chown root:root "$CONFIG"
|
||||||
|
chmod 0644 "$CONFIG"
|
||||||
|
docker exec sgs-nature-nginx nginx -t
|
||||||
|
docker exec sgs-nature-nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚时只恢复对应的 `guide` 文件或 Nginx 配置,不要删除整个 `/data/sgs-nature/web`,以免影响 `/dp`、`/gl` 等现有站点。
|
||||||
|
|
||||||
|
## 11. 当前部署记录
|
||||||
|
|
||||||
|
2026-09-15:
|
||||||
|
|
||||||
|
- 将 `8888` 从 HTTP→HTTPS 跳转改为直接提供导览 H5,公网 IP 入口为 `http://124.220.83.186:8888/`
|
||||||
|
- `8888` HTTP 与 `4433` HTTPS 共用同一 server 块,支持域名、公网 IP 和其他 Host
|
||||||
|
- 配置备份:`/data/sgs-nature/backup/nginx-before-ip-http-20260915114641.conf`
|
||||||
|
- 候选配置和运行容器的 `nginx -t` 均通过;因单文件 bind mount 的 inode 被替换,仅重启了 `sgs-nature-nginx` 以重新挂载配置
|
||||||
|
- 公网验证:IP HTTP 首页、Manifest、`/app-api/` 均返回 `200`;HTTP/HTTPS 私钥路径均返回 `404`;`4433` HTTPS 与原有 `18101 /dp/` 均返回 `200`
|
||||||
|
- 域名 HTTP 请求仍被腾讯 DNSPod `webblock` 在源站前重定向;服务器本机以域名 Host 访问 `8888` 返回 `200`
|
||||||
|
- `guide.whaoyue.com` 当前证书已于 2026-09-05 过期,待单独续期
|
||||||
|
|
||||||
|
2026-08-27:
|
||||||
|
|
||||||
|
- 使用 `pnpm build:test:h5` 构建测试 H5
|
||||||
|
- 类型检查通过
|
||||||
|
- Lint 无错误,有 1 个既有未使用变量 warning
|
||||||
|
- 构建产物 229 个文件,约 204 MB
|
||||||
|
- 发布到 `/data/sgs-nature/web/guide`
|
||||||
|
- 复用 `/data/sgs-nature/web` 现有挂载存放证书
|
||||||
|
- 更新 `/data/sgs-nature/deploy/nginx.conf`
|
||||||
|
- `sgs-nature-nginx` 执行 `nginx -t` 和 reload
|
||||||
|
- `8888` 跳转、`4433` HTTPS、API、讲解数据、SDK、Engine、GLB 和私钥保护验证通过
|
||||||
|
- 原有 `18101 /dp/` 验证通过
|
||||||
|
|
||||||
|
本次构建使用测试环境腾讯地图 Key 占位符,不代表正式生产地图 Key 已配置。正式发布前需要注入真实 Key 后重新构建并验证。
|
||||||
534
docs/deployment/zjsjgjyyzx-cn-ssl-and-verification-guide.md
Normal file
534
docs/deployment/zjsjgjyyzx-cn-ssl-and-verification-guide.md
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
# zjsjgjyyzx.cn 主站 HTTPS 与校验文件部署操作手册
|
||||||
|
|
||||||
|
最后更新:2026-09-07
|
||||||
|
|
||||||
|
## 1. 适用范围
|
||||||
|
|
||||||
|
本文档适用于腾讯云测试服务器上的主站域名 `zjsjgjyyzx.cn`,涵盖:
|
||||||
|
|
||||||
|
- Let’s Encrypt SSL 证书申请与部署
|
||||||
|
- 保留 HTTP `80` 和 HTTPS `443` 的独立访问能力
|
||||||
|
- Certbot 自动续期、证书同步和 Nginx reload
|
||||||
|
- 域名根目录 TXT 校验文件发布
|
||||||
|
- 配置验证与回滚
|
||||||
|
|
||||||
|
本文档**不适用于**导览 H5 站点 `guide.whaoyue.com:8888/4433`。该导览站点使用独立容器 `sgs-nature-nginx`,其部署手册见:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/deployment/guide-test-server-nginx-ssl.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 当前部署目标与拓扑
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 域名 | `zjsjgjyyzx.cn` |
|
||||||
|
| 服务器公网 IP | `124.220.83.186` |
|
||||||
|
| HTTP 地址 | `http://zjsjgjyyzx.cn/` |
|
||||||
|
| HTTPS 地址 | `https://zjsjgjyyzx.cn/` |
|
||||||
|
| 主 Nginx 容器 | `nginx` |
|
||||||
|
| Nginx 镜像 | `nginx:latest` |
|
||||||
|
| Docker 网络 | `bridge` |
|
||||||
|
| 宿主机端口映射 | `80 -> 80`、`443 -> 443` |
|
||||||
|
|
||||||
|
请求链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 用户 -> zjsjgjyyzx.cn:80 -> nginx Docker 容器 -> 后台主站
|
||||||
|
HTTPS 用户 -> zjsjgjyyzx.cn:443 -> nginx Docker 容器 -> 后台主站
|
||||||
|
```
|
||||||
|
|
||||||
|
当前要求是 **HTTP 和 HTTPS 均可直接访问**。不要在 HTTP server 中配置强制跳转到 HTTPS,除非业务要求变更且完成专项验证。
|
||||||
|
|
||||||
|
## 3. 关键目录与文件
|
||||||
|
|
||||||
|
### 3.1 Nginx
|
||||||
|
|
||||||
|
| 宿主机路径 | 容器路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/data/nginx/conf/nginx.conf` | `/etc/nginx/nginx.conf` | Nginx 主配置 |
|
||||||
|
| `/data/nginx/conf/conf.d/` | `/etc/nginx/conf.d/` | 站点配置目录 |
|
||||||
|
| `/data/nginx/html/` | `/usr/share/nginx/html/` | 静态资源根目录 |
|
||||||
|
| `/data/nginx/html/admin-ui/` | `/usr/share/nginx/html/admin-ui/` | 主站前端根目录 |
|
||||||
|
| `/data/nginx/ssl/` | `/etc/nginx/ssl/` | Nginx 可读取的证书目录 |
|
||||||
|
| `/data/nginx/logs/` | `/var/log/nginx/` | Nginx 日志 |
|
||||||
|
|
||||||
|
主站 HTTP 配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/conf/conf.d/zhjzpt.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
主站 HTTPS 配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/conf/conf.d/zhjzpt-ssl.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 证书
|
||||||
|
|
||||||
|
Certbot 管理的原始证书:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/letsencrypt/live/zjsjgjyyzx.cn/fullchain.pem
|
||||||
|
/etc/letsencrypt/live/zjsjgjyyzx.cn/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx 容器读取的同步副本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/ssl/zjsjgjyyzx.cn/fullchain.pem
|
||||||
|
/data/nginx/ssl/zjsjgjyyzx.cn/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
权限要求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fullchain.pem: 0644
|
||||||
|
privkey.pem: 0600
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 自动续期 Hook
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/letsencrypt/renewal-hooks/deploy/zjsjgjyyzx.cn-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
该 Hook 在证书续期成功后执行以下操作:
|
||||||
|
|
||||||
|
1. 将证书复制到 `/data/nginx/ssl/zjsjgjyyzx.cn/`。
|
||||||
|
2. 设置证书与私钥的安全权限。
|
||||||
|
3. 执行 `docker exec nginx nginx -t`。
|
||||||
|
4. 执行 `docker exec nginx nginx -s reload`。
|
||||||
|
|
||||||
|
## 4. 部署前检查
|
||||||
|
|
||||||
|
### 4.1 DNS 与备案
|
||||||
|
|
||||||
|
域名 A 记录必须统一指向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
zjsjgjyyzx.cn -> 124.220.83.186
|
||||||
|
```
|
||||||
|
|
||||||
|
至少使用多个公共 DNS 核对:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for r in 8.8.8.8 1.1.1.1 223.5.5.5 119.29.29.29; do
|
||||||
|
printf "$r A="
|
||||||
|
nslookup -type=A zjsjgjyyzx.cn "$r" 2>/dev/null |
|
||||||
|
awk '/Address: /{print $2}' | tail -1
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
如使用阿里云 DNS,可检查权威记录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short A zjsjgjyyzx.cn @dns9.hichina.com
|
||||||
|
dig +short A zjsjgjyyzx.cn @dns10.hichina.com
|
||||||
|
```
|
||||||
|
|
||||||
|
所有结果都应为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
124.220.83.186
|
||||||
|
```
|
||||||
|
|
||||||
|
中国大陆服务器使用 HTTP-01 申请证书前,应确保域名备案已通过并已生效。若备案未完成或 DNS 线路未完全刷新,Let’s Encrypt 可能访问到运营商/平台拦截页,导致验证失败。
|
||||||
|
|
||||||
|
### 4.2 Nginx 与端口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect nginx --format 'status={{.State.Status}} ports={{json .HostConfig.PortBindings}}'
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
ss -lntp | grep -E ':(80|443)([[:space:]]|$)'
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
- Nginx 容器为 `running`
|
||||||
|
- 宿主机 `80` 和 `443` 都由 Docker 映射监听
|
||||||
|
- `nginx -t` 通过
|
||||||
|
|
||||||
|
### 4.3 ACME 校验路径
|
||||||
|
|
||||||
|
HTTP-01 校验规则必须存在于 `zhjzpt.conf` 的 `server` 块内:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri =404;
|
||||||
|
default_type text/plain;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
验证校验路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /data/nginx/html/.well-known/acme-challenge
|
||||||
|
printf 'acme-check-ok\n' \
|
||||||
|
> /data/nginx/html/.well-known/acme-challenge/verify-test
|
||||||
|
|
||||||
|
curl -i http://zjsjgjyyzx.cn/.well-known/acme-challenge/verify-test
|
||||||
|
rm -f /data/nginx/html/.well-known/acme-challenge/verify-test
|
||||||
|
```
|
||||||
|
|
||||||
|
必须收到 `200 OK` 和测试文本;校验文件不能被 SPA fallback 返回为首页 HTML。
|
||||||
|
|
||||||
|
## 5. 申请 SSL 证书
|
||||||
|
|
||||||
|
### 5.1 安装 Certbot
|
||||||
|
|
||||||
|
Ubuntu 22.04:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq certbot
|
||||||
|
```
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
certbot --version
|
||||||
|
systemctl status certbot.timer --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 使用 HTTP-01 Webroot 签发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
certbot certonly \
|
||||||
|
--webroot \
|
||||||
|
-w /data/nginx/html \
|
||||||
|
-d zjsjgjyyzx.cn \
|
||||||
|
--non-interactive \
|
||||||
|
--agree-tos \
|
||||||
|
-m '<运维联系邮箱>' \
|
||||||
|
--keep-until-expiring
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 \
|
||||||
|
-in /etc/letsencrypt/live/zjsjgjyyzx.cn/fullchain.pem \
|
||||||
|
-noout -subject -issuer -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
将证书同步到 Nginx 挂载目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -o root -g root -m 0755 /data/nginx/ssl/zjsjgjyyzx.cn
|
||||||
|
install -o root -g root -m 0644 \
|
||||||
|
/etc/letsencrypt/live/zjsjgjyyzx.cn/fullchain.pem \
|
||||||
|
/data/nginx/ssl/zjsjgjyyzx.cn/fullchain.pem
|
||||||
|
install -o root -g root -m 0600 \
|
||||||
|
/etc/letsencrypt/live/zjsjgjyyzx.cn/privkey.pem \
|
||||||
|
/data/nginx/ssl/zjsjgjyyzx.cn/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 常见申请失败:CA 命中旧 IP 或拦截页
|
||||||
|
|
||||||
|
若 Certbot 输出中出现非本服务器 IP,或类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Invalid response from https://dnspod.qcloud.com/static/webblock.html
|
||||||
|
```
|
||||||
|
|
||||||
|
不要继续重复申请。先检查:
|
||||||
|
|
||||||
|
- 所有 DNS 线路是否统一为 `124.220.83.186`
|
||||||
|
- 域名备案是否已生效
|
||||||
|
- `80` 端口是否可从公网访问
|
||||||
|
- `/.well-known/acme-challenge/` 是否返回实际验证文件
|
||||||
|
- 是否存在旧 A 记录、CNAME、线路分流或平台拦截
|
||||||
|
|
||||||
|
## 6. Nginx 配置
|
||||||
|
|
||||||
|
### 6.1 HTTP 80 配置
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/conf/conf.d/zhjzpt.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 站点应继续:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name zjsjgjyyzx.cn 124.220.83.186 _;
|
||||||
|
# 保留现有静态页面与代理规则
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不要加入以下跳转规则,否则不再满足 HTTP、HTTPS 都直接可访问的要求:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 HTTPS 443 配置
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/conf/conf.d/zhjzpt-ssl.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTPS 配置应与 HTTP 站点保持功能等价,至少包含:
|
||||||
|
|
||||||
|
- 静态站点根目录:`/usr/share/nginx/html/admin-ui`
|
||||||
|
- `/kkfileview/` 代理
|
||||||
|
- `/dp/` 静态目录
|
||||||
|
- `/.well-known/acme-challenge/` 规则
|
||||||
|
- `/admin-api/` 代理
|
||||||
|
- `/app-api/` 代理
|
||||||
|
- 根路径 SPA fallback
|
||||||
|
|
||||||
|
核心 TLS 配置:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name zjsjgjyyzx.cn;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html/admin-ui;
|
||||||
|
index index.html;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/zjsjgjyyzx.cn/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/zjsjgjyyzx.cn/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# 以下保持与 HTTP 站点相同的静态和代理 location
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
配置修改前先备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
cp -p /data/nginx/conf/conf.d/zhjzpt.conf \
|
||||||
|
"/data/nginx/conf/conf.d/zhjzpt.conf.bak-$TS-before-change"
|
||||||
|
|
||||||
|
[ ! -f /data/nginx/conf/conf.d/zhjzpt-ssl.conf ] || \
|
||||||
|
cp -p /data/nginx/conf/conf.d/zhjzpt-ssl.conf \
|
||||||
|
"/data/nginx/conf/conf.d/zhjzpt-ssl.conf.bak-$TS"
|
||||||
|
```
|
||||||
|
|
||||||
|
检查并 reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
`nginx -t` 失败时不得 reload。应先恢复备份,重新检查通过后再 reload。
|
||||||
|
|
||||||
|
## 7. Certbot 自动续期
|
||||||
|
|
||||||
|
### 7.1 Deploy Hook 内容
|
||||||
|
|
||||||
|
创建:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/letsencrypt/renewal-hooks/deploy/zjsjgjyyzx.cn-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
内容:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DOMAIN="zjsjgjyyzx.cn"
|
||||||
|
LIVE="/etc/letsencrypt/live/${DOMAIN}"
|
||||||
|
TARGET="/data/nginx/ssl/${DOMAIN}"
|
||||||
|
|
||||||
|
install -d -o root -g root -m 0755 "$TARGET"
|
||||||
|
install -o root -g root -m 0644 "$LIVE/fullchain.pem" "$TARGET/fullchain.pem"
|
||||||
|
install -o root -g root -m 0600 "$LIVE/privkey.pem" "$TARGET/privkey.pem"
|
||||||
|
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
设置权限:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 700 /etc/letsencrypt/renewal-hooks/deploy/zjsjgjyyzx.cn-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 测试续期
|
||||||
|
|
||||||
|
```bash
|
||||||
|
certbot renew --dry-run
|
||||||
|
systemctl is-enabled certbot.timer
|
||||||
|
systemctl is-active certbot.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
certbot renew --dry-run:模拟续期成功
|
||||||
|
enabled
|
||||||
|
active
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 域名根目录校验文件
|
||||||
|
|
||||||
|
主站根目录是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/html/admin-ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
发布第三方验证文件时,保持原始文件名和内容。例如文件名为 `example.txt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -o root -g root -m 0644 /tmp/example.txt \
|
||||||
|
/data/nginx/html/admin-ui/example.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://zjsjgjyyzx.cn/example.txt
|
||||||
|
curl -i https://zjsjgjyyzx.cn/example.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
两个地址都应返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
当前已发布的校验文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/nginx/html/admin-ui/f7TyKy8B8f.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
对应访问地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://zjsjgjyyzx.cn/f7TyKy8B8f.txt
|
||||||
|
https://zjsjgjyyzx.cn/f7TyKy8B8f.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 部署后验证
|
||||||
|
|
||||||
|
### 9.1 HTTP 与 HTTPS 首页
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I --max-time 30 http://zjsjgjyyzx.cn/
|
||||||
|
curl -I --max-time 30 https://zjsjgjyyzx.cn/
|
||||||
|
```
|
||||||
|
|
||||||
|
两者都应返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 不应返回 `301/302` 到 HTTPS。
|
||||||
|
|
||||||
|
### 9.2 TLS 证书
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '' | openssl s_client \
|
||||||
|
-connect zjsjgjyyzx.cn:443 \
|
||||||
|
-servername zjsjgjyyzx.cn 2>/dev/null |
|
||||||
|
openssl x509 -noout -subject -issuer -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
预期主体:
|
||||||
|
|
||||||
|
```text
|
||||||
|
subject=CN=zjsjgjyyzx.cn
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 HTTPS API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||||
|
--max-time 30 \
|
||||||
|
https://zjsjgjyyzx.cn/app-api/system/menu/list
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
```text
|
||||||
|
200 application/json;charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Nginx 与续期
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
certbot certificates
|
||||||
|
certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 回滚
|
||||||
|
|
||||||
|
### 10.1 回滚 HTTPS 配置
|
||||||
|
|
||||||
|
如果 HTTPS 新配置导致 `nginx -t` 失败或业务异常:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
CFGDIR=/data/nginx/conf/conf.d
|
||||||
|
BACKUP="$CFGDIR/zhjzpt-ssl.conf.bak-YYYYMMDDHHMMSS"
|
||||||
|
|
||||||
|
test -f "$BACKUP"
|
||||||
|
cp -p "$BACKUP" "$CFGDIR/zhjzpt-ssl.conf"
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
如果需要临时停用 HTTPS server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
mv /data/nginx/conf/conf.d/zhjzpt-ssl.conf \
|
||||||
|
/data/nginx/conf/conf.d/zhjzpt-ssl.conf.disabled
|
||||||
|
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
这不会影响现有 HTTP 80 站点。
|
||||||
|
|
||||||
|
### 10.2 回滚 HTTP 配置
|
||||||
|
|
||||||
|
只在误改 `zhjzpt.conf` 时恢复:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -e
|
||||||
|
CFGDIR=/data/nginx/conf/conf.d
|
||||||
|
BACKUP="$CFGDIR/zhjzpt.conf.bak-YYYYMMDDHHMMSS-before-change"
|
||||||
|
|
||||||
|
test -f "$BACKUP"
|
||||||
|
cp -p "$BACKUP" "$CFGDIR/zhjzpt.conf"
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
不要删除 `/data/nginx/html/admin-ui/` 整个目录,以免影响后台前端和根目录校验文件。
|
||||||
|
|
||||||
|
## 11. 2026-09-07 实施记录
|
||||||
|
|
||||||
|
- 域名备案生效后,权威 DNS 和公共 DNS 均解析到 `124.220.83.186`。
|
||||||
|
- 使用 Certbot HTTP-01 Webroot 模式成功签发 `zjsjgjyyzx.cn` 证书。
|
||||||
|
- 证书签发机构:Let’s Encrypt YR2。
|
||||||
|
- 该证书有效至 `2026-12-06 01:29:53 GMT`。
|
||||||
|
- 新增 `zhjzpt-ssl.conf`,监听 `443 ssl`。
|
||||||
|
- 保留 `zhjzpt.conf` 的 `80` HTTP 站点,不执行 HTTP 到 HTTPS 跳转。
|
||||||
|
- HTTP 首页、HTTPS 首页和 HTTPS `/app-api/system/menu/list` 均验证返回 `200`。
|
||||||
|
- Certbot `renew --dry-run` 成功。
|
||||||
|
- `certbot.timer` 处于 `enabled` 和 `active` 状态。
|
||||||
|
- 发布根目录校验文件 `f7TyKy8B8f.txt`,HTTP 和 HTTPS 均返回 `200`。
|
||||||
@@ -8,14 +8,11 @@
|
|||||||
"node": ">=20 <25"
|
"node": ">=20 <25"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync:sdk-engine": "node scripts/sync-sdk-engine.cjs",
|
|
||||||
"predev:h5": "npm run sync:sdk-engine",
|
|
||||||
"dev:h5": "uni -p h5 --mode development",
|
"dev:h5": "uni -p h5 --mode development",
|
||||||
"predev:test:h5": "npm run sync:sdk-engine",
|
|
||||||
"dev:test:h5": "uni -p h5 --mode test",
|
"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:test:h5": "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": "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: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:mp-weixin": "uni -p mp-weixin --mode development",
|
||||||
"dev:test:mp-weixin": "uni -p mp-weixin --mode test",
|
"dev:test:mp-weixin": "uni -p mp-weixin --mode test",
|
||||||
"build:test:mp-weixin": "uni build -p mp-weixin --mode test",
|
"build:test:mp-weixin": "uni build -p mp-weixin --mode test",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
|
|||||||
const badPatterns = [
|
const badPatterns = [
|
||||||
'http://1.92.206.90:9000',
|
'http://1.92.206.90:9000',
|
||||||
'1.92.206.90',
|
'1.92.206.90',
|
||||||
|
'172.20.14.21',
|
||||||
'guide.whaoyue.com',
|
'guide.whaoyue.com',
|
||||||
placeholder
|
placeholder
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}`)
|
|
||||||
@@ -62,10 +62,7 @@ export interface ExplainGuideStopCatalogItem {
|
|||||||
description?: string
|
description?: string
|
||||||
audioStatus?: string
|
audioStatus?: string
|
||||||
hasAudio?: boolean
|
hasAudio?: boolean
|
||||||
hasTextRecord?: boolean
|
|
||||||
guideLevel?: string
|
guideLevel?: string
|
||||||
playTargetType?: 'ITEM' | 'STOP'
|
|
||||||
playTargetId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -144,7 +141,7 @@ const handleImageError = (stopId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availabilityLabel = (stop: ExplainGuideStopCatalogItem) => (
|
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 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() }
|
const handleScroll = (event: Event) => { const target = event.target as HTMLElement | null; if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore() }
|
||||||
|
|||||||
@@ -210,8 +210,6 @@ export interface ExplainGuideStopSelectItem {
|
|||||||
hasAudio?: boolean
|
hasAudio?: boolean
|
||||||
audioStatus?: string
|
audioStatus?: string
|
||||||
guideLevel?: string
|
guideLevel?: string
|
||||||
playTargetType?: 'ITEM' | 'STOP'
|
|
||||||
playTargetId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplainBusinessUnitSelectItem {
|
export interface ExplainBusinessUnitSelectItem {
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ import type {
|
|||||||
AudioItem
|
AudioItem
|
||||||
} from '@/components/audio/AudioPlayer.vue'
|
} from '@/components/audio/AudioPlayer.vue'
|
||||||
import {
|
import {
|
||||||
audioPlayInfoRepository,
|
|
||||||
audioReasonToText,
|
|
||||||
type AudioLanguage
|
type AudioLanguage
|
||||||
} from '@/repositories/AudioPlayInfoRepository'
|
} from '@/repositories/AudioPlayInfoRepository'
|
||||||
import type { AudioPlayTargetType } from '@/domain/museum'
|
import type { MuseumGuideAudioOption } from '@/domain/museum'
|
||||||
|
import { resolveGuideAudioOption } from '@/domain/guideAudioOptions'
|
||||||
|
|
||||||
export interface GlobalAudioSource {
|
export interface GlobalAudioSource {
|
||||||
exhibitId?: string
|
exhibitId?: string
|
||||||
targetType?: AudioPlayTargetType
|
stopId?: string
|
||||||
targetId?: string
|
|
||||||
lang?: AudioLanguage | string
|
lang?: AudioLanguage | string
|
||||||
channelCode?: string
|
channelCode?: string
|
||||||
voiceGender?: 'male' | 'female'
|
voiceGender?: 'male' | 'female'
|
||||||
|
audioOptions?: MuseumGuideAudioOption[]
|
||||||
detailRoute?: string
|
detailRoute?: string
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
@@ -73,9 +72,10 @@ const isAutoplayBlocked = (err: unknown) => {
|
|||||||
|
|
||||||
const syncAudioDuration = () => {
|
const syncAudioDuration = () => {
|
||||||
if (!audioElement) return
|
if (!audioElement) return
|
||||||
duration.value = Number.isFinite(audioElement.duration)
|
const mediaDuration = audioElement.duration
|
||||||
? audioElement.duration
|
duration.value = Number.isFinite(mediaDuration) && mediaDuration > 0
|
||||||
: currentAudio.value?.duration || 0
|
? mediaDuration
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureAudioElement = () => {
|
const ensureAudioElement = () => {
|
||||||
@@ -179,7 +179,7 @@ const stopPlayback = () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
pendingLanguage.value = ''
|
pendingLanguage.value = ''
|
||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
duration.value = currentAudio.value?.duration || 0
|
duration.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguage) => {
|
const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguage) => {
|
||||||
@@ -192,14 +192,14 @@ const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguag
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toSwitchedAudioItem = (
|
const toSwitchedAudioItem = (
|
||||||
playInfo: Awaited<ReturnType<typeof audioPlayInfoRepository.getPlayInfo>>,
|
audioOption: MuseumGuideAudioOption,
|
||||||
lang: AudioLanguage
|
lang: AudioLanguage
|
||||||
): AudioItem => ({
|
): AudioItem => ({
|
||||||
id: `play-${playInfo.audioId || `${playInfo.targetType}-${playInfo.targetId}-${lang}`}`,
|
id: `channel-${audioOption.channelCode}`,
|
||||||
name: currentAudio.value?.name || currentSource.value?.title || playInfo.title || '讲解音频',
|
name: currentAudio.value?.name || currentSource.value?.title || audioOption.displayName || '讲解音频',
|
||||||
audioUrl: playInfo.playUrl || '',
|
audioUrl: audioOption.playUrl,
|
||||||
image: currentAudio.value?.image,
|
image: currentAudio.value?.image,
|
||||||
duration: typeof playInfo.duration === 'number' ? playInfo.duration : currentAudio.value?.duration,
|
duration: audioOption.duration || currentAudio.value?.duration,
|
||||||
language: lang,
|
language: lang,
|
||||||
supportedLanguages: currentAudio.value?.supportedLanguages
|
supportedLanguages: currentAudio.value?.supportedLanguages
|
||||||
})
|
})
|
||||||
@@ -245,7 +245,7 @@ const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
|
|||||||
|
|
||||||
if (isNewAudio) {
|
if (isNewAudio) {
|
||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
duration.value = audio.duration || 0
|
duration.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.getAttribute('src') !== audio.audioUrl) {
|
if (element.getAttribute('src') !== audio.audioUrl) {
|
||||||
@@ -292,7 +292,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const source = currentSource.value
|
const source = currentSource.value
|
||||||
if (!source?.targetType || !source.targetId) {
|
if (!source?.audioOptions?.length) {
|
||||||
error.value = '当前讲解暂不支持语言切换'
|
error.value = '当前讲解暂不支持语言切换'
|
||||||
showToast(error.value)
|
showToast(error.value)
|
||||||
return false
|
return false
|
||||||
@@ -318,20 +318,19 @@ const switchLanguage = async (lang: AudioLanguage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playInfo = await audioPlayInfoRepository.getPlayInfo({
|
const audioOption = resolveGuideAudioOption(
|
||||||
targetType: source.targetType,
|
source.audioOptions,
|
||||||
targetId: source.targetId,
|
|
||||||
lang,
|
lang,
|
||||||
refresh: true
|
source.voiceGender || 'female'
|
||||||
})
|
)
|
||||||
|
|
||||||
if (!playInfo.playable || !playInfo.playUrl) {
|
if (!audioOption?.playUrl) {
|
||||||
stopAudioElement()
|
stopAudioElement()
|
||||||
currentSource.value = nextSource
|
currentSource.value = nextSource
|
||||||
currentAudio.value = currentAudio.value
|
currentAudio.value = currentAudio.value
|
||||||
? {
|
? {
|
||||||
...currentAudio.value,
|
...currentAudio.value,
|
||||||
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
|
id: `unavailable-${source.stopId || source.exhibitId || 'detail'}-${lang}`,
|
||||||
audioUrl: '',
|
audioUrl: '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
language: lang
|
language: lang
|
||||||
@@ -343,14 +342,19 @@ const switchLanguage = async (lang: AudioLanguage) => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
duration.value = 0
|
duration.value = 0
|
||||||
error.value = audioReasonToText(playInfo.reason || 'NO_PUBLISHED_AUDIO')
|
error.value = '当前语言暂无语音讲解'
|
||||||
showToast(error.value)
|
showToast(error.value)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSource.value = nextSource
|
const nextTrackSource: GlobalAudioSource = {
|
||||||
return await play(toSwitchedAudioItem(playInfo, lang), {
|
...nextSource,
|
||||||
source: nextSource,
|
channelCode: audioOption.channelCode,
|
||||||
|
voiceGender: audioOption.gender
|
||||||
|
}
|
||||||
|
currentSource.value = nextTrackSource
|
||||||
|
return await play(toSwitchedAudioItem(audioOption, lang), {
|
||||||
|
source: nextTrackSource,
|
||||||
retryOnError: retryOnError || undefined,
|
retryOnError: retryOnError || undefined,
|
||||||
displayMode: preservedMode
|
displayMode: preservedMode
|
||||||
})
|
})
|
||||||
@@ -361,7 +365,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
|
|||||||
if (currentAudio.value) {
|
if (currentAudio.value) {
|
||||||
currentAudio.value = {
|
currentAudio.value = {
|
||||||
...currentAudio.value,
|
...currentAudio.value,
|
||||||
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
|
id: `unavailable-${source.stopId || source.exhibitId || 'detail'}-${lang}`,
|
||||||
audioUrl: '',
|
audioUrl: '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
language: lang
|
language: lang
|
||||||
@@ -462,14 +466,11 @@ const unregisterHost = (hostId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentSource = (source: Pick<GlobalAudioSource, 'targetType' | 'targetId' | 'lang' | 'channelCode'>) => {
|
const isCurrentSource = (source: Pick<GlobalAudioSource, 'stopId' | 'lang' | 'channelCode'>) => {
|
||||||
if (!currentAudio.value || !currentSource.value) return false
|
if (!currentAudio.value || !currentSource.value) return false
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
source.targetType
|
(!source.stopId || currentSource.value.stopId === source.stopId)
|
||||||
&& source.targetId
|
|
||||||
&& currentSource.value.targetType === source.targetType
|
|
||||||
&& currentSource.value.targetId === source.targetId
|
|
||||||
&& (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang)
|
&& (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang)
|
||||||
&& (!source.channelCode || currentSource.value.channelCode === source.channelCode)
|
&& (!source.channelCode || currentSource.value.channelCode === source.channelCode)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ export const dataSourceConfig = {
|
|||||||
audioApiTimeoutMs: normalizeTimeout(
|
audioApiTimeoutMs: normalizeTimeout(
|
||||||
import.meta.env.VITE_AUDIO_API_TIMEOUT_MS || import.meta.env.VITE_EXPLAIN_API_TIMEOUT_MS,
|
import.meta.env.VITE_AUDIO_API_TIMEOUT_MS || import.meta.env.VITE_EXPLAIN_API_TIMEOUT_MS,
|
||||||
defaultExplainRequestTimeoutMs
|
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'
|
export const isSgsSdkMode = () => dataSourceConfig.guideDataMode === 'sdk'
|
||||||
|
|||||||
@@ -158,9 +158,14 @@ export interface BackendCatalogStopItem {
|
|||||||
hasAudio?: boolean | null
|
hasAudio?: boolean | null
|
||||||
audioStatus?: string | null
|
audioStatus?: string | null
|
||||||
supportedLanguages?: string[] | null
|
supportedLanguages?: string[] | null
|
||||||
hasTextRecord?: boolean | null
|
audioOptions?: Array<{
|
||||||
playTargetType?: string | null
|
version?: string | null
|
||||||
playTargetId?: string | number | null
|
languageCode?: string | null
|
||||||
|
languageName?: string | null
|
||||||
|
gender?: string | null
|
||||||
|
displayName?: string | null
|
||||||
|
isDefault?: boolean | null
|
||||||
|
}> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendExplainAdapterResult {
|
export interface BackendExplainAdapterResult {
|
||||||
@@ -339,8 +344,6 @@ export const toCatalogGuideStop = (
|
|||||||
const id = stringifyId(source.stopId) || stringifyId(source.id)
|
const id = stringifyId(source.stopId) || stringifyId(source.id)
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
|
|
||||||
const playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP'
|
|
||||||
const playTargetId = stringifyId(source.playTargetId) || id
|
|
||||||
const imageStatus = firstText(source.imageStatus) || 'MISSING'
|
const imageStatus = firstText(source.imageStatus) || 'MISSING'
|
||||||
const canUseStopImage = imageStatus === 'READY'
|
const canUseStopImage = imageStatus === 'READY'
|
||||||
|
|
||||||
@@ -350,8 +353,6 @@ export const toCatalogGuideStop = (
|
|||||||
hallId: hall?.id,
|
hallId: hall?.id,
|
||||||
hallName: hall?.name,
|
hallName: hall?.name,
|
||||||
floorId: stringifyId(source.floorId) || hall?.floorId,
|
floorId: stringifyId(source.floorId) || hall?.floorId,
|
||||||
targetType: playTargetType,
|
|
||||||
targetId: playTargetId,
|
|
||||||
coverImageUrl: canUseStopImage
|
coverImageUrl: canUseStopImage
|
||||||
? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
|
? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -359,7 +360,6 @@ export const toCatalogGuideStop = (
|
|||||||
hasAudio: source.hasAudio === true,
|
hasAudio: source.hasAudio === true,
|
||||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||||
hasTextRecord: source.hasTextRecord === true,
|
|
||||||
poiId: stringifyId(source.poiId) || undefined,
|
poiId: stringifyId(source.poiId) || undefined,
|
||||||
mapX: normalizeNumber(source.mapX),
|
mapX: normalizeNumber(source.mapX),
|
||||||
mapY: normalizeNumber(source.mapY),
|
mapY: normalizeNumber(source.mapY),
|
||||||
@@ -373,8 +373,27 @@ export const toCatalogGuideStop = (
|
|||||||
linkedExhibits: (source.linkedExhibits || [])
|
linkedExhibits: (source.linkedExhibits || [])
|
||||||
.map(toCatalogLinkedExhibitSummary)
|
.map(toCatalogLinkedExhibitSummary)
|
||||||
.filter(Boolean) as NonNullable<ExplainGuideStop['linkedExhibits']>,
|
.filter(Boolean) as NonNullable<ExplainGuideStop['linkedExhibits']>,
|
||||||
playTargetType,
|
stopId: id,
|
||||||
playTargetId
|
// 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,
|
audioAvailable: audioReady,
|
||||||
audioStatus: stop.audioStatus,
|
audioStatus: stop.audioStatus,
|
||||||
supportedLanguages: stop.supportedLanguages,
|
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,
|
imageStatus: stop.imageStatus,
|
||||||
linkedExhibitCount: stop.linkedExhibitCount,
|
linkedExhibitCount: stop.linkedExhibitCount,
|
||||||
isSharedStop: stop.isSharedStop,
|
isSharedStop: stop.isSharedStop,
|
||||||
linkedExhibits: stop.linkedExhibits,
|
linkedExhibits: stop.linkedExhibits,
|
||||||
stopInfoAvailable: true,
|
stopInfoAvailable: true,
|
||||||
resolvedStopId: stop.id,
|
resolvedStopId: stop.id
|
||||||
playTargetType: stop.playTargetType || stop.targetType || 'STOP',
|
|
||||||
playTargetId: stop.playTargetId || stop.targetId || stop.id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
AudioPlayTargetType,
|
|
||||||
GuideAudioGender,
|
GuideAudioGender,
|
||||||
MuseumGuideAudioOption
|
MuseumGuideAudioOption
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
@@ -9,6 +8,54 @@ import {
|
|||||||
|
|
||||||
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
|
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 {
|
export interface BackendGuideStopLinkedExhibit {
|
||||||
id?: string | number | null
|
id?: string | number | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@@ -20,81 +67,42 @@ export interface BackendGuideStopLinkedExhibit {
|
|||||||
sortOrder?: number | string | null
|
sortOrder?: number | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendGuideAudioOption {
|
export interface GuideTextVariant {
|
||||||
channelCode?: string | null
|
version: GuideAudioVersion
|
||||||
displayName?: string | null
|
languageCode: GuideAudioLanguage
|
||||||
version?: string | null
|
languageName?: string
|
||||||
languageCode?: string | null
|
text?: string
|
||||||
languageName?: string | null
|
textLength?: number
|
||||||
gender?: string | null
|
textHash?: string
|
||||||
playUrl?: string | null
|
|
||||||
duration?: number | string | null
|
|
||||||
format?: string | null
|
|
||||||
isDefault?: boolean | null
|
|
||||||
sortOrder?: number | string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendGuideStopInfo {
|
export interface GuideAudioTrack extends MuseumGuideAudioOption {
|
||||||
available?: boolean
|
version: GuideAudioVersion
|
||||||
targetType?: string | null
|
|
||||||
targetId?: string | number | null
|
|
||||||
resolvedStopId?: string | number | null
|
|
||||||
stopId?: string | number | null
|
|
||||||
poiId?: string | number | null
|
|
||||||
floorId?: string | number | null
|
|
||||||
mapX?: number | string | null
|
|
||||||
mapY?: number | string | null
|
|
||||||
lang?: string | null
|
|
||||||
title?: string | null
|
|
||||||
description?: string | null
|
|
||||||
coverImageUrl?: string | null
|
|
||||||
galleryUrls?: string | string[] | null
|
|
||||||
imageStatus?: string | null
|
|
||||||
imageSource?: string | null
|
|
||||||
linkedExhibits?: BackendGuideStopLinkedExhibit[] | null
|
|
||||||
playTargetType?: string | null
|
|
||||||
playTargetId?: string | number | null
|
|
||||||
hasAudio?: boolean
|
|
||||||
hasText?: boolean
|
|
||||||
supportedLanguages?: string[] | null
|
|
||||||
audioStatus?: string | null
|
|
||||||
audioOptions?: BackendGuideAudioOption[] | null
|
|
||||||
audioOptionCount?: number | string | null
|
|
||||||
reason?: string | null
|
|
||||||
linkedExhibitCount?: number | string | null
|
|
||||||
isSharedStop?: boolean | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendAudioPlayInfo {
|
export interface GuideStopDetail {
|
||||||
playable?: boolean
|
id: string
|
||||||
targetType?: string | null
|
version: GuideAudioVersion
|
||||||
targetId?: string | number | null
|
outlineId?: string
|
||||||
lang?: string | null
|
poiId?: string
|
||||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
floorId?: string
|
||||||
audioId?: string | number | null
|
mapX?: number
|
||||||
title?: string | null
|
mapY?: number
|
||||||
duration?: number | null
|
name: string
|
||||||
format?: string | null
|
description?: string
|
||||||
playUrl?: string | null
|
coverImageUrl?: string
|
||||||
expiresAt?: string | null
|
galleryUrls: string[]
|
||||||
subtitleUrl?: string | null
|
imageStatus: 'READY' | 'MISSING' | string
|
||||||
hasText?: boolean
|
linkedExhibits: GuideStopLinkedExhibit[]
|
||||||
fallback?: boolean
|
linkedExhibitCount?: number
|
||||||
fallbackReason?: string | null
|
isSharedStop: boolean
|
||||||
reason?: string | null
|
recommendedTrackCode?: string
|
||||||
}
|
textVariants: GuideTextVariant[]
|
||||||
|
audioTracks: GuideAudioTrack[]
|
||||||
export interface BackendAudioTextInfo {
|
hasText: boolean
|
||||||
available?: boolean
|
hasAudio: boolean
|
||||||
targetType?: string | null
|
textVariantCount: number
|
||||||
targetId?: string | number | null
|
audioTrackCount: number
|
||||||
lang?: string | null
|
|
||||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
|
||||||
title?: string | null
|
|
||||||
text?: string | null
|
|
||||||
textLength?: number | null
|
|
||||||
textHash?: string | null
|
|
||||||
reason?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuideStopLinkedExhibit {
|
export interface GuideStopLinkedExhibit {
|
||||||
@@ -108,68 +116,6 @@ export interface GuideStopLinkedExhibit {
|
|||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuideStopInfo {
|
|
||||||
available: boolean
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
resolvedStopId?: string
|
|
||||||
poiId?: string
|
|
||||||
floorId?: string
|
|
||||||
mapX?: number
|
|
||||||
mapY?: number
|
|
||||||
lang: GuideAudioLanguage
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
coverImageUrl?: string
|
|
||||||
galleryUrls: string[]
|
|
||||||
imageStatus: 'READY' | 'MISSING' | string
|
|
||||||
imageSource?: string
|
|
||||||
linkedExhibits: GuideStopLinkedExhibit[]
|
|
||||||
playTargetType: AudioPlayTargetType
|
|
||||||
playTargetId: string
|
|
||||||
hasAudio: boolean
|
|
||||||
hasText: boolean
|
|
||||||
supportedLanguages: GuideAudioLanguage[]
|
|
||||||
audioStatus: 'READY' | 'MISSING' | string
|
|
||||||
audioOptions: MuseumGuideAudioOption[]
|
|
||||||
audioOptionCount?: number
|
|
||||||
reason?: string
|
|
||||||
linkedExhibitCount?: number
|
|
||||||
isSharedStop?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GuideAudioPlayInfo {
|
|
||||||
playable: boolean
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
lang: GuideAudioLanguage
|
|
||||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
|
||||||
audioId?: string
|
|
||||||
title?: string
|
|
||||||
duration?: number
|
|
||||||
format?: string
|
|
||||||
playUrl?: string
|
|
||||||
expiresAt?: string
|
|
||||||
subtitleUrl?: string
|
|
||||||
hasText: boolean
|
|
||||||
fallback: boolean
|
|
||||||
fallbackReason?: string
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GuideAudioTextInfo {
|
|
||||||
available: boolean
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
lang: GuideAudioLanguage
|
|
||||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
|
||||||
title?: string
|
|
||||||
text?: string
|
|
||||||
textLength?: number
|
|
||||||
textHash?: string
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const stringifyId = (value: string | number | null | undefined) => (
|
const stringifyId = (value: string | number | null | undefined) => (
|
||||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||||
)
|
)
|
||||||
@@ -183,11 +129,6 @@ const normalizeNumber = (value: number | string | null | undefined) => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
|
|
||||||
const normalized = value?.toUpperCase()
|
|
||||||
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeGuideAudioLanguage = (
|
export const normalizeGuideAudioLanguage = (
|
||||||
value: string | null | undefined
|
value: string | null | undefined
|
||||||
): GuideAudioLanguage => {
|
): GuideAudioLanguage => {
|
||||||
@@ -197,36 +138,49 @@ export const normalizeGuideAudioLanguage = (
|
|||||||
return 'zh-CN'
|
return 'zh-CN'
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
|
|
||||||
Array.from(new Set((languages || [])
|
|
||||||
.map((language) => {
|
|
||||||
const normalized = language?.trim().toLowerCase()
|
|
||||||
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return normalizeGuideAudioLanguage(language)
|
|
||||||
})
|
|
||||||
.filter(Boolean))) as GuideAudioLanguage[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const normalizeAudioGender = (value: string | null | undefined): GuideAudioGender | null => {
|
const normalizeAudioGender = (value: string | null | undefined): GuideAudioGender | null => {
|
||||||
const normalized = value?.trim().toLowerCase()
|
const normalized = value?.trim().toLowerCase()
|
||||||
return normalized === 'male' || normalized === 'female' ? normalized : null
|
return normalized === 'male' || normalized === 'female' ? normalized : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefined): MuseumGuideAudioOption[] => (
|
const normalizeAudioVersion = (value: string | null | undefined): GuideAudioVersion => (
|
||||||
|
value?.trim().toLowerCase() === 'extended' ? 'extended' : 'standard'
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalizeTextVariants = (items: BackendGuideTextVariant[] | null | undefined): GuideTextVariant[] => (
|
||||||
(items || [])
|
(items || [])
|
||||||
.map<MuseumGuideAudioOption | null>((item) => {
|
.map<GuideTextVariant | null>((item) => {
|
||||||
const channelCode = item.channelCode?.trim()
|
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 languageCode = normalizeGuideAudioLanguage(item.languageCode)
|
||||||
const gender = normalizeAudioGender(item.gender)
|
const gender = normalizeAudioGender(item.gender)
|
||||||
const playUrl = normalizeSameOriginPublicUrl(item.playUrl)
|
const playUrl = normalizeSameOriginPublicUrl(item.playUrl)
|
||||||
if (!channelCode || !gender || !playUrl) return null
|
if (!gender || !playUrl) return null
|
||||||
|
const version = normalizeAudioVersion(item.version)
|
||||||
return {
|
return {
|
||||||
channelCode,
|
channelCode: trackCodeFor(version, languageCode, gender),
|
||||||
displayName: item.displayName?.trim() || undefined,
|
displayName: item.displayName?.trim() || undefined,
|
||||||
version: item.version?.trim() || undefined,
|
version,
|
||||||
languageCode,
|
languageCode,
|
||||||
languageName: item.languageName?.trim() || undefined,
|
languageName: item.languageName?.trim() || undefined,
|
||||||
gender,
|
gender,
|
||||||
@@ -234,13 +188,50 @@ const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefin
|
|||||||
duration: normalizeNumber(item.duration),
|
duration: normalizeNumber(item.duration),
|
||||||
format: item.format?.trim() || undefined,
|
format: item.format?.trim() || undefined,
|
||||||
isDefault: item.isDefault === true,
|
isDefault: item.isDefault === true,
|
||||||
sortOrder: normalizeNumber(item.sortOrder)
|
sortOrder: undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(Boolean) as MuseumGuideAudioOption[]
|
.filter(Boolean) as GuideAudioTrack[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
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: BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
||||||
if (!value) return []
|
if (!value) return []
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((entry) => normalizeSameOriginPublicUrl(String(entry))).filter(Boolean)
|
return value.map((entry) => normalizeSameOriginPublicUrl(String(entry))).filter(Boolean)
|
||||||
@@ -263,11 +254,6 @@ const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGu
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeNarrationTier = (value: string | null | undefined) => {
|
|
||||||
const normalized = value?.toUpperCase()
|
|
||||||
return normalized === 'STANDARD' || normalized === 'EXTENDED' ? normalized : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
|
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
|
||||||
(items || [])
|
(items || [])
|
||||||
.map<GuideStopLinkedExhibit | null>((item) => {
|
.map<GuideStopLinkedExhibit | null>((item) => {
|
||||||
@@ -287,114 +273,3 @@ const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null |
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as GuideStopLinkedExhibit[]
|
.filter(Boolean) as GuideStopLinkedExhibit[]
|
||||||
)
|
)
|
||||||
|
|
||||||
export const toGuideStopInfo = (
|
|
||||||
source: BackendGuideStopInfo,
|
|
||||||
fallback: {
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
lang: GuideAudioLanguage
|
|
||||||
}
|
|
||||||
): GuideStopInfo => {
|
|
||||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
|
||||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
|
||||||
const playTargetType = normalizeTargetType(source.playTargetType, targetType)
|
|
||||||
const playTargetId = stringifyId(source.playTargetId) || targetId
|
|
||||||
const imageStatus = source.imageStatus || 'MISSING'
|
|
||||||
const canUseStopImages = imageStatus === 'READY'
|
|
||||||
const galleryUrls = canUseStopImages ? parseGalleryUrls(source.galleryUrls) : []
|
|
||||||
const coverImageUrl = canUseStopImages
|
|
||||||
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
|
||||||
: undefined
|
|
||||||
const audioOptions = normalizeAudioOptions(source.audioOptions)
|
|
||||||
const supportedLanguages = Array.from(new Set([
|
|
||||||
...normalizeSupportedLanguages(source.supportedLanguages),
|
|
||||||
...audioOptions.map((option) => option.languageCode as GuideAudioLanguage)
|
|
||||||
]))
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: source.available === true,
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
resolvedStopId: stringifyId(source.resolvedStopId) || stringifyId(source.stopId) || undefined,
|
|
||||||
poiId: stringifyId(source.poiId) || undefined,
|
|
||||||
floorId: stringifyId(source.floorId) || undefined,
|
|
||||||
mapX: normalizeNumber(source.mapX),
|
|
||||||
mapY: normalizeNumber(source.mapY),
|
|
||||||
lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
|
|
||||||
title: source.title?.trim() || '讲解内容',
|
|
||||||
description: source.description?.trim() || undefined,
|
|
||||||
coverImageUrl,
|
|
||||||
galleryUrls,
|
|
||||||
imageStatus,
|
|
||||||
imageSource: source.imageSource || undefined,
|
|
||||||
linkedExhibits: normalizeLinkedExhibits(source.linkedExhibits),
|
|
||||||
playTargetType,
|
|
||||||
playTargetId,
|
|
||||||
hasAudio: source.hasAudio === true,
|
|
||||||
hasText: source.hasText === true,
|
|
||||||
supportedLanguages,
|
|
||||||
audioStatus: source.audioStatus || 'MISSING',
|
|
||||||
audioOptions,
|
|
||||||
audioOptionCount: normalizeNumber(source.audioOptionCount),
|
|
||||||
reason: source.reason || undefined,
|
|
||||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
|
||||||
isSharedStop: source.isSharedStop === true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toGuideAudioPlayInfo = (
|
|
||||||
source: BackendAudioPlayInfo,
|
|
||||||
fallback: {
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
lang: GuideAudioLanguage
|
|
||||||
}
|
|
||||||
): GuideAudioPlayInfo => {
|
|
||||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
|
||||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
|
||||||
|
|
||||||
return {
|
|
||||||
playable: source.playable === true,
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
|
|
||||||
narrationTier: normalizeNarrationTier(source.narrationTier),
|
|
||||||
audioId: stringifyId(source.audioId) || undefined,
|
|
||||||
title: source.title?.trim() || undefined,
|
|
||||||
duration: typeof source.duration === 'number' && Number.isFinite(source.duration) ? source.duration : undefined,
|
|
||||||
format: source.format?.trim() || undefined,
|
|
||||||
playUrl: normalizeSameOriginPublicUrl(source.playUrl) || undefined,
|
|
||||||
expiresAt: source.expiresAt || undefined,
|
|
||||||
subtitleUrl: normalizeSameOriginPublicUrl(source.subtitleUrl) || undefined,
|
|
||||||
hasText: source.hasText === true,
|
|
||||||
fallback: source.fallback === true,
|
|
||||||
fallbackReason: source.fallbackReason || undefined,
|
|
||||||
reason: source.reason || undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toGuideAudioTextInfo = (
|
|
||||||
source: BackendAudioTextInfo,
|
|
||||||
fallback: {
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
lang: GuideAudioLanguage
|
|
||||||
}
|
|
||||||
): GuideAudioTextInfo => {
|
|
||||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
|
||||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: source.available === true,
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
|
|
||||||
narrationTier: normalizeNarrationTier(source.narrationTier),
|
|
||||||
title: source.title?.trim() || undefined,
|
|
||||||
text: source.text || undefined,
|
|
||||||
textLength: typeof source.textLength === 'number' && Number.isFinite(source.textLength) ? source.textLength : undefined,
|
|
||||||
textHash: source.textHash || undefined,
|
|
||||||
reason: source.reason || undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -259,10 +259,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
if (inflight) return inflight
|
if (inflight) return inflight
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({ lang: catalogLang() })
|
||||||
includeChildren: 'true',
|
|
||||||
lang: catalogLang()
|
|
||||||
})
|
|
||||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
|
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
|
||||||
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
||||||
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
||||||
@@ -511,9 +508,20 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
if (inflight) return inflight
|
if (inflight) return inflight
|
||||||
|
|
||||||
const promise = (async () => {
|
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.
|
// The outline response carries authoritative counts. Stops are loaded only after unit selection.
|
||||||
const units = toCatalogBusinessUnits(normalizedHallId, outlines, [])
|
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)
|
this.setCached(this.businessUnitCache, key, units)
|
||||||
return units
|
return units
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import type {
|
|
||||||
ExplainGuideStop
|
|
||||||
} from './museum'
|
|
||||||
|
|
||||||
export interface ExplainDetailTarget {
|
|
||||||
targetType: 'STOP'
|
|
||||||
targetId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeExplainDetailTargetFromGuideStop = (
|
|
||||||
guideStop: Pick<ExplainGuideStop, 'id'>
|
|
||||||
): ExplainDetailTarget => ({
|
|
||||||
targetType: 'STOP',
|
|
||||||
targetId: String(guideStop.id)
|
|
||||||
})
|
|
||||||
@@ -160,6 +160,15 @@ export interface MuseumGuideAudioOption {
|
|||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MuseumGuideTextVariant {
|
||||||
|
version: 'standard' | 'extended'
|
||||||
|
languageCode: string
|
||||||
|
languageName?: string
|
||||||
|
text?: string
|
||||||
|
textLength?: number
|
||||||
|
textHash?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MuseumExhibit {
|
export interface MuseumExhibit {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -196,6 +205,11 @@ export interface MuseumExhibit {
|
|||||||
audioStatus?: string
|
audioStatus?: string
|
||||||
supportedLanguages?: string[]
|
supportedLanguages?: string[]
|
||||||
audioOptions?: MuseumGuideAudioOption[]
|
audioOptions?: MuseumGuideAudioOption[]
|
||||||
|
textVariants?: MuseumGuideTextVariant[]
|
||||||
|
recommendedTrackCode?: string
|
||||||
|
guideVersion?: 'standard' | 'extended'
|
||||||
|
textVariantCount?: number
|
||||||
|
audioTrackCount?: number
|
||||||
audioVariants?: Record<string, {
|
audioVariants?: Record<string, {
|
||||||
title?: string
|
title?: string
|
||||||
text?: string
|
text?: string
|
||||||
@@ -262,6 +276,8 @@ export interface ExplainGuideStop {
|
|||||||
}>
|
}>
|
||||||
playTargetType?: AudioPlayTargetType
|
playTargetType?: AudioPlayTargetType
|
||||||
playTargetId?: string
|
playTargetId?: string
|
||||||
|
stopId?: string
|
||||||
|
audioOptions?: MuseumGuideAudioOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplainGuideStopPage {
|
export interface ExplainGuideStopPage {
|
||||||
|
|||||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@@ -25,8 +25,6 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_SGS_SDK_ORIGIN?: string
|
readonly VITE_SGS_SDK_ORIGIN?: string
|
||||||
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
|
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
|
||||||
readonly VITE_TENCENT_MAP_KEY?: string
|
readonly VITE_TENCENT_MAP_KEY?: string
|
||||||
readonly VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST?: string
|
|
||||||
readonly VITE_PUBLIC_LEGACY_AUDIO_HOST?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -172,10 +172,7 @@ import {
|
|||||||
toExplainDetailPageViewModel,
|
toExplainDetailPageViewModel,
|
||||||
type ExplainDetailPageViewModel
|
type ExplainDetailPageViewModel
|
||||||
} from '@/view-models/explainViewModels'
|
} from '@/view-models/explainViewModels'
|
||||||
import type {
|
import type { GuideAudioGender } from '@/domain/museum'
|
||||||
AudioPlayTargetType,
|
|
||||||
GuideAudioGender
|
|
||||||
} from '@/domain/museum'
|
|
||||||
import type {
|
import type {
|
||||||
AudioLanguage
|
AudioLanguage
|
||||||
} from '@/repositories/AudioPlayInfoRepository'
|
} from '@/repositories/AudioPlayInfoRepository'
|
||||||
@@ -222,12 +219,11 @@ const activeTopTab = ref<GuideTopTab>('explain')
|
|||||||
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')
|
||||||
const selectedAudioGender = ref<GuideAudioGender>('male')
|
const selectedAudioGender = ref<GuideAudioGender>('female')
|
||||||
let languageSwitchSequence = 0
|
let languageSwitchSequence = 0
|
||||||
const detailEntryRequest = ref<{
|
const detailEntryRequest = ref<{
|
||||||
exhibitId: string
|
exhibitId: string
|
||||||
targetType?: AudioPlayTargetType
|
stopId: string
|
||||||
targetId?: string
|
|
||||||
hallId?: string
|
hallId?: string
|
||||||
hallName?: string
|
hallName?: string
|
||||||
floorId?: string
|
floorId?: string
|
||||||
@@ -255,9 +251,17 @@ const isAudioLanguage = (value: unknown): value is AudioLanguage => (
|
|||||||
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
|
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(() => (
|
const supportedDetailLanguages = computed(() => (
|
||||||
resolveGuideAudioLanguages(
|
resolveGuideAudioLanguages(
|
||||||
exhibit.value.audio.audioOptions,
|
activeDetailAudioOptions.value,
|
||||||
exhibit.value.audio.supportedLanguages
|
exhibit.value.audio.supportedLanguages
|
||||||
).filter(isAudioLanguage)
|
).filter(isAudioLanguage)
|
||||||
))
|
))
|
||||||
@@ -316,7 +320,7 @@ const heroSubtitle = computed(() => [
|
|||||||
detailMeta.value
|
detailMeta.value
|
||||||
].filter(Boolean).join(' · '))
|
].filter(Boolean).join(' · '))
|
||||||
const selectedDetailAudioOption = computed(() => resolveGuideAudioOption(
|
const selectedDetailAudioOption = computed(() => resolveGuideAudioOption(
|
||||||
exhibit.value.audio.audioOptions,
|
activeDetailAudioOptions.value,
|
||||||
selectedAudioLanguage.value,
|
selectedAudioLanguage.value,
|
||||||
selectedAudioGender.value
|
selectedAudioGender.value
|
||||||
))
|
))
|
||||||
@@ -325,7 +329,7 @@ const currentDetailAudioGender = computed(() => (
|
|||||||
|| resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value)
|
|| resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value)
|
||||||
))
|
))
|
||||||
const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender(
|
const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender(
|
||||||
exhibit.value.audio.audioOptions,
|
activeDetailAudioOptions.value,
|
||||||
selectedAudioLanguage.value
|
selectedAudioLanguage.value
|
||||||
))
|
))
|
||||||
const detailAudioVoiceLabel = computed(() => {
|
const detailAudioVoiceLabel = computed(() => {
|
||||||
@@ -337,21 +341,18 @@ const selectedDetailAudioAvailable = computed(() => (
|
|||||||
|| exhibit.value.audio.status === 'playable'
|
|| exhibit.value.audio.status === 'playable'
|
||||||
))
|
))
|
||||||
const currentAudioTarget = computed(() => ({
|
const currentAudioTarget = computed(() => ({
|
||||||
targetType: exhibit.value.audio.playTargetType || 'ITEM',
|
stopId: exhibit.value.audio.stopId || exhibit.value.id,
|
||||||
targetId: exhibit.value.audio.playTargetId || exhibit.value.id,
|
|
||||||
lang: selectedAudioLanguage.value,
|
lang: selectedAudioLanguage.value,
|
||||||
channelCode: selectedDetailAudioOption.value?.channelCode
|
channelCode: selectedDetailAudioOption.value?.channelCode
|
||||||
}))
|
}))
|
||||||
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
|
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
|
||||||
const isCurrentDetailAudioTarget = computed(() => {
|
const isCurrentDetailAudioTarget = computed(() => {
|
||||||
const source = globalAudioPlayer.currentSource.value
|
const source = globalAudioPlayer.currentSource.value
|
||||||
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
|
const stopId = exhibit.value.audio.stopId || exhibit.value.id
|
||||||
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
|
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
globalAudioPlayer.currentAudio.value
|
globalAudioPlayer.currentAudio.value
|
||||||
&& source?.targetType === targetType
|
&& source?.stopId === stopId
|
||||||
&& source.targetId === targetId
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
let detailAudioClosedOnExit = false
|
let detailAudioClosedOnExit = false
|
||||||
@@ -371,14 +372,9 @@ const formatDetailAudioTime = (seconds?: number) => {
|
|||||||
|
|
||||||
return `${minutes}:${remainSeconds.toString().padStart(2, '0')}`
|
return `${minutes}:${remainSeconds.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
const detailAudioDurationSeconds = computed(() => {
|
const detailAudioDurationSeconds = computed(() => (
|
||||||
if (!isCurrentDetailAudioTarget.value) return exhibit.value.audio.duration || 0
|
isCurrentDetailAudioTarget.value ? globalAudioPlayer.duration.value : 0
|
||||||
|
))
|
||||||
return globalAudioPlayer.duration.value
|
|
||||||
|| globalAudioPlayer.currentAudio.value?.duration
|
|
||||||
|| exhibit.value.audio.duration
|
|
||||||
|| 0
|
|
||||||
})
|
|
||||||
const detailAudioCurrentSeconds = computed(() => (
|
const detailAudioCurrentSeconds = computed(() => (
|
||||||
isCurrentDetailAudioTarget.value ? globalAudioPlayer.currentTime.value : 0
|
isCurrentDetailAudioTarget.value ? globalAudioPlayer.currentTime.value : 0
|
||||||
))
|
))
|
||||||
@@ -418,8 +414,7 @@ const isCurrentDetailAudioError = computed(() => {
|
|||||||
const source = globalAudioPlayer.currentSource.value
|
const source = globalAudioPlayer.currentSource.value
|
||||||
return Boolean(
|
return Boolean(
|
||||||
globalAudioPlayer.error.value
|
globalAudioPlayer.error.value
|
||||||
&& source?.targetType === currentAudioTarget.value.targetType
|
&& source?.stopId === currentAudioTarget.value.stopId
|
||||||
&& source.targetId === currentAudioTarget.value.targetId
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const isDetailAudioLoading = computed(() => (
|
const isDetailAudioLoading = computed(() => (
|
||||||
@@ -451,25 +446,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 (
|
const loadFullDetailTextForLanguage = async (
|
||||||
request: NonNullable<typeof detailEntryRequest.value>,
|
_request: NonNullable<typeof detailEntryRequest.value>,
|
||||||
lang: AudioLanguage,
|
lang: AudioLanguage,
|
||||||
preferredViewModel?: ExplainDetailPageViewModel
|
preferredViewModel?: ExplainDetailPageViewModel
|
||||||
) => {
|
) => {
|
||||||
@@ -479,21 +457,15 @@ const loadFullDetailTextForLanguage = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
|
const viewModel = preferredViewModel || exhibit.value
|
||||||
...request,
|
const variants = viewModel.audio.textVariants || []
|
||||||
lang
|
const version = viewModel.audio.version || 'standard'
|
||||||
}))
|
const detailText = variants.find((variant) => (
|
||||||
|
variant.version === version && variant.languageCode === lang
|
||||||
if (viewModel.audio.hasText) {
|
))?.text || (lang === 'yue-HK'
|
||||||
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
|
? variants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN')?.text
|
||||||
if (selection.available) {
|
: undefined)
|
||||||
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
|
applyDetailText(lang, detailText || viewModel.summary)
|
||||||
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDetailText(lang, viewModel.body || viewModel.summary)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('讲解正文加载失败:', lang, error)
|
console.warn('讲解正文加载失败:', lang, error)
|
||||||
applyDetailText(lang)
|
applyDetailText(lang)
|
||||||
@@ -517,8 +489,12 @@ const loadExplainDetail = async (
|
|||||||
|
|
||||||
heroImageIndex.value = 0
|
heroImageIndex.value = 0
|
||||||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
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
|
selectedAudioLanguage.value = resolvedLanguage
|
||||||
|
selectedAudioGender.value = recommended?.gender || resolveGuideAudioGender(resolvedLanguage, 'female')
|
||||||
|
|
||||||
if (resolvedLanguage !== lang) {
|
if (resolvedLanguage !== lang) {
|
||||||
replaceDetailRouteLanguage(resolvedLanguage)
|
replaceDetailRouteLanguage(resolvedLanguage)
|
||||||
@@ -532,28 +508,22 @@ const loadExplainDetail = async (
|
|||||||
|
|
||||||
onLoad(async (options: any = {}) => {
|
onLoad(async (options: any = {}) => {
|
||||||
detailAudioClosedOnExit = false
|
detailAudioClosedOnExit = false
|
||||||
selectedAudioGender.value = 'male'
|
selectedAudioGender.value = 'female'
|
||||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||||
if (isGuideTopTab(tab)) {
|
if (isGuideTopTab(tab)) {
|
||||||
activeTopTab.value = tab
|
activeTopTab.value = tab
|
||||||
}
|
}
|
||||||
|
|
||||||
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
|
const rawStopId = Array.isArray(options.stopId) ? options.stopId[0] : options.stopId
|
||||||
if (!exhibitId) {
|
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'
|
detailState.value = 'missing'
|
||||||
return
|
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 rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
|
||||||
const lang = typeof rawLang === 'string'
|
const lang = typeof rawLang === 'string'
|
||||||
? normalizeGuideAudioLanguage(rawLang)
|
? normalizeGuideAudioLanguage(rawLang)
|
||||||
@@ -562,8 +532,7 @@ onLoad(async (options: any = {}) => {
|
|||||||
try {
|
try {
|
||||||
detailEntryRequest.value = {
|
detailEntryRequest.value = {
|
||||||
exhibitId,
|
exhibitId,
|
||||||
targetType,
|
stopId,
|
||||||
targetId,
|
|
||||||
hallId: Array.isArray(options.hallId) ? options.hallId[0] : options.hallId,
|
hallId: Array.isArray(options.hallId) ? options.hallId[0] : options.hallId,
|
||||||
hallName: Array.isArray(options.hallName) ? options.hallName[0] : options.hallName,
|
hallName: Array.isArray(options.hallName) ? options.hallName[0] : options.hallName,
|
||||||
floorId: Array.isArray(options.floorId) ? options.floorId[0] : options.floorId,
|
floorId: Array.isArray(options.floorId) ? options.floorId[0] : options.floorId,
|
||||||
@@ -684,17 +653,27 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|
|||||||
applyDetailText(lang)
|
applyDetailText(lang)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const exhibitData = await explainUseCase.enterExplainDetail({
|
|
||||||
...request,
|
|
||||||
lang
|
|
||||||
})
|
|
||||||
if (requestSequence !== languageSwitchSequence) return
|
if (requestSequence !== languageSwitchSequence) return
|
||||||
|
const audioOption = resolveGuideAudioOption(
|
||||||
heroImageIndex.value = 0
|
activeDetailAudioOptions.value,
|
||||||
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
|
lang,
|
||||||
exhibit.value = nextViewModel
|
selectedAudioGender.value
|
||||||
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
|
)
|
||||||
|
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) {
|
if (shouldContinueAudio) {
|
||||||
// The player must become actionable before resuming with the new language track.
|
// The player must become actionable before resuming with the new language track.
|
||||||
languageSwitchLoading.value = false
|
languageSwitchLoading.value = false
|
||||||
@@ -742,13 +721,11 @@ watch(
|
|||||||
() => globalAudioPlayer.currentSource.value,
|
() => globalAudioPlayer.currentSource.value,
|
||||||
(source) => {
|
(source) => {
|
||||||
const lang = source?.lang
|
const lang = source?.lang
|
||||||
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
|
const stopId = exhibit.value.audio.stopId || exhibit.value.id
|
||||||
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isAudioLanguage(lang)
|
isAudioLanguage(lang)
|
||||||
&& source?.targetType === targetType
|
&& source?.stopId === stopId
|
||||||
&& source.targetId === targetId
|
|
||||||
) {
|
) {
|
||||||
void handleLanguageChange(lang, { syncAudio: false })
|
void handleLanguageChange(lang, { syncAudio: false })
|
||||||
}
|
}
|
||||||
@@ -761,12 +738,7 @@ function buildDetailRoute(lang: AudioLanguage = selectedAudioLanguage.value) {
|
|||||||
id: request?.exhibitId || exhibit.value.id,
|
id: request?.exhibitId || exhibit.value.id,
|
||||||
tab: activeTopTab.value
|
tab: activeTopTab.value
|
||||||
})
|
})
|
||||||
if (currentAudioTarget.value.targetType) {
|
params.set('stopId', request?.stopId || exhibit.value.audio.stopId || exhibit.value.id)
|
||||||
params.set('targetType', currentAudioTarget.value.targetType)
|
|
||||||
}
|
|
||||||
if (currentAudioTarget.value.targetId) {
|
|
||||||
params.set('targetId', currentAudioTarget.value.targetId)
|
|
||||||
}
|
|
||||||
if (lang) {
|
if (lang) {
|
||||||
params.set('lang', lang)
|
params.set('lang', lang)
|
||||||
}
|
}
|
||||||
@@ -782,7 +754,7 @@ const toAudioItem = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: media.id,
|
id: media.id,
|
||||||
name: selection.playInfo?.title || selection.exhibit.name,
|
name: selection.exhibit.name,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
image: heroImage.value,
|
image: heroImage.value,
|
||||||
duration: media.duration,
|
duration: media.duration,
|
||||||
@@ -806,11 +778,10 @@ const refreshCurrentAudioOnce = async (message: string) => {
|
|||||||
audioDuration: exhibit.value.audio.duration,
|
audioDuration: exhibit.value.audio.duration,
|
||||||
audioStatus: 'READY',
|
audioStatus: 'READY',
|
||||||
audioLanguage: selectedAudioLanguage.value,
|
audioLanguage: selectedAudioLanguage.value,
|
||||||
audioOptions: exhibit.value.audio.audioOptions,
|
audioOptions: activeDetailAudioOptions.value,
|
||||||
|
guideVersion: detailAudioVersion.value,
|
||||||
playTargetType: exhibit.value.audio.playTargetType,
|
playTargetType: exhibit.value.audio.playTargetType,
|
||||||
playTargetId: exhibit.value.audio.playTargetId
|
playTargetId: exhibit.value.audio.playTargetId
|
||||||
}, {
|
|
||||||
refreshPlayInfo: true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
retryingAudio.value = false
|
retryingAudio.value = false
|
||||||
@@ -833,11 +804,11 @@ const playDetailAudio = async (audio: AudioItem) => {
|
|||||||
await globalAudioPlayer.play(audio, {
|
await globalAudioPlayer.play(audio, {
|
||||||
source: {
|
source: {
|
||||||
exhibitId: exhibit.value.id,
|
exhibitId: exhibit.value.id,
|
||||||
targetType: currentAudioTarget.value.targetType,
|
stopId: currentAudioTarget.value.stopId,
|
||||||
targetId: currentAudioTarget.value.targetId,
|
|
||||||
lang: selectedAudioLanguage.value,
|
lang: selectedAudioLanguage.value,
|
||||||
channelCode: selectedDetailAudioOption.value?.channelCode,
|
channelCode: selectedDetailAudioOption.value?.channelCode,
|
||||||
voiceGender: currentDetailAudioGender.value,
|
voiceGender: currentDetailAudioGender.value,
|
||||||
|
audioOptions: activeDetailAudioOptions.value,
|
||||||
title: audio.name,
|
title: audio.name,
|
||||||
detailRoute: buildDetailRoute()
|
detailRoute: buildDetailRoute()
|
||||||
},
|
},
|
||||||
@@ -883,7 +854,8 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
|
|||||||
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
||||||
audioLanguage: selectedAudioLanguage.value,
|
audioLanguage: selectedAudioLanguage.value,
|
||||||
audioUnavailableReason: exhibit.value.audio.unavailableReason,
|
audioUnavailableReason: exhibit.value.audio.unavailableReason,
|
||||||
audioOptions: exhibit.value.audio.audioOptions,
|
audioOptions: activeDetailAudioOptions.value,
|
||||||
|
guideVersion: detailAudioVersion.value,
|
||||||
playTargetType: exhibit.value.audio.playTargetType,
|
playTargetType: exhibit.value.audio.playTargetType,
|
||||||
playTargetId: exhibit.value.audio.playTargetId
|
playTargetId: exhibit.value.audio.playTargetId
|
||||||
}, {
|
}, {
|
||||||
@@ -945,12 +917,31 @@ const fallbackToExplainObjectList = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExplainDetailPageStackEntry = {
|
||||||
|
route?: string
|
||||||
|
}
|
||||||
|
|
||||||
const returnToExplainObjectList = () => {
|
const returnToExplainObjectList = () => {
|
||||||
closeDetailAudioOnExit()
|
closeDetailAudioOnExit()
|
||||||
uni.navigateBack({
|
|
||||||
delta: 1,
|
// H5 history can omit intermediate uni-app pages after a refresh or deep link.
|
||||||
fail: fallbackToExplainObjectList
|
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
|
const handleBack = returnToExplainObjectList
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
|||||||
import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue'
|
import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue'
|
||||||
import { explainUseCase } from '@/usecases/explainUseCase'
|
import { explainUseCase } from '@/usecases/explainUseCase'
|
||||||
import type { ExplainGuideStop } from '@/domain/museum'
|
import type { ExplainGuideStop } from '@/domain/museum'
|
||||||
import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget'
|
|
||||||
import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs'
|
import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -53,11 +52,8 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogIte
|
|||||||
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
|
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
|
||||||
description: stop.description,
|
description: stop.description,
|
||||||
hasAudio: stop.hasAudio,
|
hasAudio: stop.hasAudio,
|
||||||
hasTextRecord: stop.hasTextRecord,
|
|
||||||
audioStatus: stop.audioStatus,
|
audioStatus: stop.audioStatus,
|
||||||
guideLevel: stop.guideLevel,
|
guideLevel: stop.guideLevel
|
||||||
playTargetType: stop.playTargetType,
|
|
||||||
playTargetId: stop.playTargetId
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const syncPageTitle = (title: string) => {
|
const syncPageTitle = (title: string) => {
|
||||||
@@ -130,10 +126,8 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleExplainGuideStopClick = (stop: ExplainGuideStopCatalogItem) => {
|
const handleExplainGuideStopClick = (stop: ExplainGuideStopCatalogItem) => {
|
||||||
const target = stop.playTargetType && stop.playTargetId
|
const stopId = stop.id
|
||||||
? { targetType: stop.playTargetType, targetId: stop.playTargetId }
|
const params = new URLSearchParams({ id: stopId, stopId, tab: 'explain' })
|
||||||
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id })
|
|
||||||
const params = new URLSearchParams({ id: stop.id, tab: 'explain', targetType: target.targetType, targetId: target.targetId })
|
|
||||||
// 分页对象可能未回填展厅字段,详情返回必须保留当前列表的展厅上下文。
|
// 分页对象可能未回填展厅字段,详情返回必须保留当前列表的展厅上下文。
|
||||||
const hallId = stop.hallId || selectedExplainHallId.value
|
const hallId = stop.hallId || selectedExplainHallId.value
|
||||||
const hallName = stop.hallName || selectedExplainHallName.value
|
const hallName = stop.hallName || selectedExplainHallName.value
|
||||||
|
|||||||
@@ -209,11 +209,7 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
|
|||||||
id: exhibit.id,
|
id: exhibit.id,
|
||||||
tab: activeTopTab.value
|
tab: activeTopTab.value
|
||||||
})
|
})
|
||||||
|
params.set('stopId', exhibit.playTargetId || exhibit.id)
|
||||||
if (exhibit.playTargetType && exhibit.playTargetId) {
|
|
||||||
params.set('targetType', exhibit.playTargetType)
|
|
||||||
params.set('targetId', exhibit.playTargetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/exhibit/detail?${params.toString()}`
|
url: `/pages/exhibit/detail?${params.toString()}`
|
||||||
|
|||||||
@@ -1,96 +1,74 @@
|
|||||||
|
import { dataSourceConfig } from '@/config/dataSource'
|
||||||
import {
|
import {
|
||||||
dataSourceConfig
|
toGuideStopDetail,
|
||||||
} from '@/config/dataSource'
|
type BackendGuideStopDetail,
|
||||||
import {
|
|
||||||
type AudioPlayTargetType
|
|
||||||
} from '@/domain/museum'
|
|
||||||
import {
|
|
||||||
toGuideAudioPlayInfo,
|
|
||||||
toGuideAudioTextInfo,
|
|
||||||
toGuideStopInfo,
|
|
||||||
type BackendAudioPlayInfo,
|
|
||||||
type BackendAudioTextInfo,
|
|
||||||
type BackendGuideStopInfo,
|
|
||||||
type GuideAudioPlayInfo,
|
|
||||||
type GuideAudioTextInfo,
|
|
||||||
type GuideAudioLanguage,
|
type GuideAudioLanguage,
|
||||||
type GuideStopInfo
|
type GuideAudioVersion,
|
||||||
|
type GuideStopDetail
|
||||||
} from '@/data/adapters/guideStopInfoAdapter'
|
} from '@/data/adapters/guideStopInfoAdapter'
|
||||||
|
|
||||||
export type AudioLanguage = GuideAudioLanguage
|
export type AudioLanguage = GuideAudioLanguage
|
||||||
|
|
||||||
export interface AudioPlayInfoRequest {
|
export interface GetStopDetailOptions {
|
||||||
targetType: AudioPlayTargetType
|
version?: GuideAudioVersion
|
||||||
targetId: string
|
|
||||||
lang?: AudioLanguage
|
|
||||||
refresh?: boolean
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GuideStopInfoResponse {
|
|
||||||
code: number
|
|
||||||
msg?: string
|
|
||||||
data?: BackendGuideStopInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioPlayInfo extends GuideAudioPlayInfo {}
|
|
||||||
|
|
||||||
interface AudioPlayInfoResponse {
|
|
||||||
code: number
|
|
||||||
msg?: string
|
|
||||||
data?: BackendAudioPlayInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioTextInfoRequest {
|
|
||||||
targetType: AudioPlayTargetType
|
|
||||||
targetId: string
|
|
||||||
lang?: AudioLanguage
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioTextInfo extends GuideAudioTextInfo {}
|
|
||||||
|
|
||||||
interface AudioTextInfoResponse {
|
|
||||||
code: number
|
|
||||||
msg?: string
|
|
||||||
data?: BackendAudioTextInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioPlayInfoRepository {
|
export interface AudioPlayInfoRepository {
|
||||||
getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo>
|
getStopDetail(stopId: string, options?: GetStopDetailOptions): Promise<GuideStopDetail>
|
||||||
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
clearCache(stopId?: string): void
|
||||||
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
|
|
||||||
clearCache(lang?: AudioLanguage): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GuideStopDetailResponse {
|
||||||
|
code: number
|
||||||
|
msg?: string
|
||||||
|
data?: BackendGuideStopDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOP_DETAIL_TTL_MS = 10 * 60_000
|
||||||
|
const CACHE_MAX_ENTRIES = 80
|
||||||
|
|
||||||
|
const normalizeDetailVersion = (version?: GuideAudioVersion): GuideAudioVersion => (
|
||||||
|
version === 'extended' ? 'extended' : 'standard'
|
||||||
|
)
|
||||||
|
|
||||||
|
const detailCacheKey = (stopId: string, version: GuideAudioVersion) => `${stopId}:version:${version}`
|
||||||
|
|
||||||
const reasonMessageMap: Record<string, string> = {
|
const reasonMessageMap: Record<string, string> = {
|
||||||
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
|
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
|
||||||
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
||||||
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
||||||
NO_TEXT: '当前语言暂无讲解词',
|
NO_TEXT: '当前语言暂无讲解词',
|
||||||
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
||||||
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
GUIDE_STOP_NOT_EXISTS: '该讲解点不存在或暂不可展示',
|
||||||
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
|
||||||
SERVICE_ERROR: '语音服务暂不可用,请稍后重试'
|
SERVICE_ERROR: '语音服务暂不可用,请稍后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseJsonPayload = <T>(payload: unknown): T => {
|
export const audioReasonToText = (reason?: string | null) => (
|
||||||
if (typeof payload === 'string') {
|
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
|
||||||
return JSON.parse(payload) as T
|
)
|
||||||
}
|
|
||||||
|
|
||||||
|
const parseJsonPayload = <T>(payload: unknown): T => {
|
||||||
|
if (typeof payload === 'string') return JSON.parse(payload) as T
|
||||||
return 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) => {
|
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
url,
|
url,
|
||||||
@@ -99,10 +77,9 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
|||||||
success: (response) => {
|
success: (response) => {
|
||||||
const statusCode = Number(response.statusCode || 0)
|
const statusCode = Number(response.statusCode || 0)
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
reject(new Error(`语音播放解析接口请求失败: ${statusCode}`))
|
reject(new Error(`讲解点详情请求失败: ${statusCode}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
resolve(parseJsonPayload<T>(response.data))
|
resolve(parseJsonPayload<T>(response.data))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -113,245 +90,81 @@ 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 {
|
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||||
private readonly stopInfoCache = new Map<string, CacheEntry<GuideStopInfo>>()
|
private readonly detailCache = new Map<string, CacheEntry<GuideStopDetail>>()
|
||||||
private readonly cache = new Map<string, CacheEntry<AudioPlayInfo>>()
|
private readonly detailInflight = new Map<string, Promise<GuideStopDetail>>()
|
||||||
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 epoch = 0
|
private epoch = 0
|
||||||
|
|
||||||
private getCached<T>(cache: Map<string, CacheEntry<T>>, key: string): T | null {
|
private getCached(stopId: string, version: GuideAudioVersion) {
|
||||||
const entry = cache.get(key)
|
const key = detailCacheKey(stopId, version)
|
||||||
|
const entry = this.detailCache.get(key)
|
||||||
if (!entry) return null
|
if (!entry) return null
|
||||||
if (entry.expiresAt <= Date.now()) {
|
if (entry.expiresAt <= Date.now()) {
|
||||||
cache.delete(key)
|
this.detailCache.delete(key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
cache.delete(key)
|
this.detailCache.delete(key)
|
||||||
cache.set(key, entry)
|
this.detailCache.set(key, entry)
|
||||||
return entry.value
|
return entry.value
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCached<T>(cache: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number) {
|
private setCached(stopId: string, version: GuideAudioVersion, value: GuideStopDetail) {
|
||||||
cache.delete(key)
|
const key = detailCacheKey(stopId, version)
|
||||||
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
this.detailCache.delete(key)
|
||||||
while (cache.size > CACHE_MAX_ENTRIES) {
|
this.detailCache.set(key, { value, expiresAt: Date.now() + STOP_DETAIL_TTL_MS })
|
||||||
const oldestKey = cache.keys().next().value
|
while (this.detailCache.size > CACHE_MAX_ENTRIES) {
|
||||||
|
const oldestKey = this.detailCache.keys().next().value
|
||||||
if (!oldestKey) break
|
if (!oldestKey) break
|
||||||
cache.delete(oldestKey)
|
this.detailCache.delete(oldestKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async coalesce<T>(
|
async getStopDetail(stopId: string, options: GetStopDetailOptions = {}): Promise<GuideStopDetail> {
|
||||||
inflight: Map<string, Promise<T>>,
|
const normalizedStopId = String(stopId || '').trim()
|
||||||
key: string,
|
if (!normalizedStopId) throw new Error('讲解点 ID 不能为空')
|
||||||
loader: () => Promise<T>
|
const version = normalizeDetailVersion(options.version)
|
||||||
): Promise<T> {
|
const key = detailCacheKey(normalizedStopId, version)
|
||||||
const existing = inflight.get(key)
|
|
||||||
|
const cached = this.getCached(normalizedStopId, version)
|
||||||
|
if (!options.refresh && cached) return cached
|
||||||
|
|
||||||
|
const existing = this.detailInflight.get(key)
|
||||||
if (existing) return existing
|
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) {
|
||||||
|
const error = new Error(response.msg || '讲解点详情加载失败')
|
||||||
|
;(error as Error & { code?: number }).code = response.code
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const detail = toGuideStopDetail(response.data)
|
||||||
|
if (epoch === this.epoch) this.setCached(normalizedStopId, version, detail)
|
||||||
|
return detail
|
||||||
|
})
|
||||||
|
|
||||||
|
this.detailInflight.set(key, request)
|
||||||
try {
|
try {
|
||||||
return await promise
|
return await request
|
||||||
} finally {
|
} 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> {
|
clearCache(stopId?: string) {
|
||||||
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) {
|
|
||||||
this.epoch += 1
|
this.epoch += 1
|
||||||
if (!lang) {
|
if (stopId?.trim()) {
|
||||||
this.stopInfoCache.clear()
|
const normalizedStopId = stopId.trim()
|
||||||
this.cache.clear()
|
this.detailCache.delete(detailCacheKey(normalizedStopId, 'standard'))
|
||||||
this.textCache.clear()
|
this.detailCache.delete(detailCacheKey(normalizedStopId, 'extended'))
|
||||||
unavailableAudioApiEndpoints.clear()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.detailCache.clear()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
AudioLanguage,
|
AudioLanguage
|
||||||
AudioPlayInfoRequest
|
|
||||||
} from '@/repositories/AudioPlayInfoRepository'
|
} 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 {
|
export interface PublishedExhibitAudioResolution {
|
||||||
targets: AudioPlayInfoRequest[]
|
targets: PublishedExhibitAudioTarget[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublishedExhibitAudioRepository {
|
export interface PublishedExhibitAudioRepository {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
AudioPlayTargetType,
|
|
||||||
ExplainBusinessUnit,
|
ExplainBusinessUnit,
|
||||||
ExplainGuideStop,
|
ExplainGuideStop,
|
||||||
ExplainGuideStopPage,
|
ExplainGuideStopPage,
|
||||||
@@ -18,20 +17,15 @@ import {
|
|||||||
audioPlayInfoRepository,
|
audioPlayInfoRepository,
|
||||||
audioReasonToText,
|
audioReasonToText,
|
||||||
type AudioLanguage,
|
type AudioLanguage,
|
||||||
type AudioPlayInfo,
|
type AudioPlayInfoRepository
|
||||||
type AudioPlayInfoRepository,
|
|
||||||
type GuideStopInfoRequest,
|
|
||||||
type AudioTextInfo
|
|
||||||
} from '@/repositories/AudioPlayInfoRepository'
|
} from '@/repositories/AudioPlayInfoRepository'
|
||||||
import type {
|
import type {
|
||||||
GuideStopInfo
|
GuideAudioVersion,
|
||||||
|
GuideStopDetail
|
||||||
} from '@/data/adapters/guideStopInfoAdapter'
|
} from '@/data/adapters/guideStopInfoAdapter'
|
||||||
import {
|
import {
|
||||||
dataSourceConfig
|
dataSourceConfig
|
||||||
} from '@/config/dataSource'
|
} from '@/config/dataSource'
|
||||||
import {
|
|
||||||
normalizeExplainDetailTargetFromGuideStop
|
|
||||||
} from '@/domain/explainDetailTarget'
|
|
||||||
import {
|
import {
|
||||||
resolveGuideAudioLanguages,
|
resolveGuideAudioLanguages,
|
||||||
resolveGuideAudioOption
|
resolveGuideAudioOption
|
||||||
@@ -43,14 +37,13 @@ export interface ExplainAudioSelection {
|
|||||||
media: MediaAsset | null
|
media: MediaAsset | null
|
||||||
playable: boolean
|
playable: boolean
|
||||||
unavailableMessage?: string
|
unavailableMessage?: string
|
||||||
playInfo?: AudioPlayInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplainDetailEntryRequest {
|
export interface ExplainDetailEntryRequest {
|
||||||
exhibitId: string
|
exhibitId: string
|
||||||
targetType?: AudioPlayTargetType
|
stopId?: string
|
||||||
targetId?: string
|
|
||||||
lang?: AudioLanguage
|
lang?: AudioLanguage
|
||||||
|
version?: GuideAudioVersion
|
||||||
// Navigation context is non-authoritative and keeps deep links independent of catalog loading.
|
// Navigation context is non-authoritative and keeps deep links independent of catalog loading.
|
||||||
hallId?: string
|
hallId?: string
|
||||||
hallName?: string
|
hallName?: string
|
||||||
@@ -61,7 +54,13 @@ export interface ExplainDetailEntryRequest {
|
|||||||
|
|
||||||
export interface ExplainTextSelection {
|
export interface ExplainTextSelection {
|
||||||
exhibit: MuseumExhibit
|
exhibit: MuseumExhibit
|
||||||
textInfo: AudioTextInfo
|
textInfo: {
|
||||||
|
available: boolean
|
||||||
|
lang: AudioLanguage
|
||||||
|
text?: string
|
||||||
|
textLength?: number
|
||||||
|
textHash?: string
|
||||||
|
}
|
||||||
available: boolean
|
available: boolean
|
||||||
unavailableMessage?: string
|
unavailableMessage?: string
|
||||||
}
|
}
|
||||||
@@ -96,54 +95,17 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAudioTarget(exhibit: MuseumExhibit, track?: ExplainTrack | null) {
|
private resolveDetailStopId(request: ExplainDetailEntryRequest) {
|
||||||
const targetType: AudioPlayTargetType = track?.playTargetType
|
return String(request.stopId || request.exhibitId || '').trim()
|
||||||
|| 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 async resolveStaticDetailFallback(
|
private async resolveStaticDetailFallback(
|
||||||
request: ExplainDetailEntryRequest,
|
request: ExplainDetailEntryRequest,
|
||||||
entryTarget: Required<GuideStopInfoRequest>
|
entryTarget: { stopId: string }
|
||||||
): Promise<MuseumExhibit | null> {
|
): Promise<MuseumExhibit | null> {
|
||||||
if (entryTarget.targetType !== 'ITEM' && entryTarget.targetType !== 'STOP') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateIds = Array.from(new Set([
|
const candidateIds = Array.from(new Set([
|
||||||
request.exhibitId,
|
request.exhibitId,
|
||||||
entryTarget.targetId
|
entryTarget.stopId
|
||||||
].map((id) => id?.trim()).filter(Boolean)))
|
].map((id) => id?.trim()).filter(Boolean)))
|
||||||
|
|
||||||
for (const candidateId of candidateIds) {
|
for (const candidateId of candidateIds) {
|
||||||
@@ -156,201 +118,89 @@ export class ExplainUseCase {
|
|||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private languageLabel(language: AudioLanguage) {
|
private selectTextVariant(detail: GuideStopDetail, language: AudioLanguage, version = detail.version) {
|
||||||
if (language === 'en-US') return '英文'
|
const exact = detail.textVariants.find((variant) => (
|
||||||
if (language === 'yue-HK') return '粤语'
|
variant.version === version && variant.languageCode === language
|
||||||
return '中文'
|
))
|
||||||
|
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 {
|
private toExhibitFromStopDetail(
|
||||||
const variant = exhibit.audioVariants?.[language]
|
detail: GuideStopDetail,
|
||||||
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,
|
|
||||||
fallback?: MuseumExhibit | null,
|
fallback?: MuseumExhibit | null,
|
||||||
navigationContext?: ExplainDetailEntryRequest
|
navigationContext?: ExplainDetailEntryRequest,
|
||||||
|
language: AudioLanguage = 'zh-CN',
|
||||||
|
gender: GuideAudioGender = 'female'
|
||||||
): MuseumExhibit {
|
): MuseumExhibit {
|
||||||
const linkedPrimary = stopInfo.linkedExhibits[0]
|
const linkedPrimary = detail.linkedExhibits[0]
|
||||||
const coverImage = stopInfo.imageStatus === 'READY'
|
const textVariant = this.selectTextVariant(detail, language)
|
||||||
? stopInfo.coverImageUrl
|
const currentVersionTracks = detail.audioTracks.filter((track) => track.version === detail.version)
|
||||||
: undefined
|
const recommendedTrack = currentVersionTracks.find((track) => track.channelCode === detail.recommendedTrackCode)
|
||||||
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
const preferredGender = language === 'yue-HK'
|
||||||
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
? 'female'
|
||||||
// Product defaults Chinese and English to male even if the management default is female.
|
: recommendedTrack?.languageCode === language ? recommendedTrack.gender : gender
|
||||||
const audioOption = resolveGuideAudioOption(stopInfo.audioOptions, stopInfo.lang, 'male')
|
const audioOption = resolveGuideAudioOption(currentVersionTracks, language, preferredGender)
|
||||||
const audioAvailable = Boolean(audioOption?.playUrl) || stopInfo.audioStatus === 'READY'
|
const audioAvailable = Boolean(audioOption?.playUrl)
|
||||||
const supportedLanguages = resolveGuideAudioLanguages(
|
const supportedLanguages = resolveGuideAudioLanguages(
|
||||||
stopInfo.audioOptions,
|
currentVersionTracks,
|
||||||
stopInfo.supportedLanguages
|
currentVersionTracks.map((track) => track.languageCode)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(fallback || {}),
|
...(fallback || {}),
|
||||||
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
id: detail.id,
|
||||||
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
name: detail.name || fallback?.name || linkedPrimary?.name || '讲解内容',
|
||||||
hallId: fallback?.hallId || navigationContext?.hallId,
|
hallId: fallback?.hallId || navigationContext?.hallId,
|
||||||
hallName: fallback?.hallName || navigationContext?.hallName,
|
hallName: fallback?.hallName || navigationContext?.hallName,
|
||||||
floorId: stopInfo.floorId || fallback?.floorId || navigationContext?.floorId,
|
floorId: detail.floorId || fallback?.floorId || navigationContext?.floorId,
|
||||||
floorLabel: fallback?.floorLabel || navigationContext?.floorLabel,
|
floorLabel: fallback?.floorLabel || navigationContext?.floorLabel,
|
||||||
image: coverImage,
|
image: detail.imageStatus === 'READY' ? detail.coverImageUrl : undefined,
|
||||||
description,
|
description: detail.description || fallback?.description || '该讲解暂无简介。',
|
||||||
poiId: stopInfo.poiId || fallback?.poiId || navigationContext?.poiId,
|
poiId: detail.poiId || fallback?.poiId || navigationContext?.poiId,
|
||||||
sourcePoiId: stopInfo.poiId || fallback?.sourcePoiId,
|
sourcePoiId: detail.poiId || fallback?.sourcePoiId,
|
||||||
mapX: stopInfo.mapX ?? fallback?.mapX,
|
mapX: detail.mapX ?? fallback?.mapX,
|
||||||
mapY: stopInfo.mapY ?? fallback?.mapY,
|
mapY: detail.mapY ?? fallback?.mapY,
|
||||||
location: fallback?.location,
|
guideTitle: detail.name,
|
||||||
year: fallback?.year,
|
guideText: textVariant?.text || detail.description || fallback?.guideText || fallback?.description,
|
||||||
material: fallback?.material,
|
|
||||||
size: fallback?.size,
|
|
||||||
tags: fallback?.tags,
|
|
||||||
guideTitle: stopInfo.title || fallback?.guideTitle,
|
|
||||||
guideText: stopInfo.description || fallback?.guideText || fallback?.description,
|
|
||||||
audioUrl: audioOption?.playUrl,
|
audioUrl: audioOption?.playUrl,
|
||||||
audioDuration: audioOption?.duration,
|
audioDuration: audioOption?.duration,
|
||||||
audioLanguage: stopInfo.lang,
|
audioLanguage: language,
|
||||||
audioHasText: stopInfo.hasText,
|
audioHasText: Boolean(textVariant?.text),
|
||||||
audioText: undefined,
|
audioText: textVariant?.text,
|
||||||
audioTextLength: undefined,
|
audioTextLength: textVariant?.textLength,
|
||||||
audioTextHash: undefined,
|
audioTextHash: textVariant?.textHash,
|
||||||
audioNarrationTier: undefined,
|
audioUnavailableReason: audioAvailable ? undefined : audioReasonToText(detail.hasAudio ? undefined : 'NO_PUBLISHED_AUDIO'),
|
||||||
audioUnavailableReason: audioAvailable
|
|
||||||
? undefined
|
|
||||||
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
|
|
||||||
audioAvailable,
|
audioAvailable,
|
||||||
audioStatus: audioAvailable ? 'READY' : stopInfo.audioStatus,
|
audioStatus: audioAvailable ? 'READY' : 'MISSING',
|
||||||
supportedLanguages,
|
supportedLanguages,
|
||||||
audioOptions: stopInfo.audioOptions,
|
audioOptions: detail.audioTracks,
|
||||||
imageStatus: stopInfo.imageStatus,
|
textVariants: detail.textVariants,
|
||||||
imageSource: stopInfo.imageSource,
|
recommendedTrackCode: detail.recommendedTrackCode,
|
||||||
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
guideVersion: detail.version,
|
||||||
linkedExhibitCount: stopInfo.linkedExhibitCount,
|
textVariantCount: detail.textVariantCount,
|
||||||
isSharedStop: stopInfo.isSharedStop,
|
audioTrackCount: detail.audioTrackCount,
|
||||||
linkedExhibits: stopInfo.linkedExhibits,
|
imageStatus: detail.imageStatus,
|
||||||
stopInfoAvailable: stopInfo.available,
|
galleryUrls: detail.imageStatus === 'READY' ? detail.galleryUrls : [],
|
||||||
stopInfoReason: stopInfo.reason,
|
linkedExhibitCount: detail.linkedExhibitCount,
|
||||||
resolvedStopId: stopInfo.resolvedStopId,
|
isSharedStop: detail.isSharedStop,
|
||||||
playTargetType: audioTarget.targetType,
|
linkedExhibits: detail.linkedExhibits,
|
||||||
playTargetId: audioTarget.targetId
|
stopInfoAvailable: true,
|
||||||
|
resolvedStopId: detail.id,
|
||||||
|
playTargetType: 'STOP',
|
||||||
|
playTargetId: detail.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPlayInfo(
|
private async enrichExhibitAudio(exhibit: MuseumExhibit): Promise<MuseumExhibit> {
|
||||||
exhibit: MuseumExhibit,
|
if (exhibit.audioOptions?.length || !exhibit.id) return exhibit
|
||||||
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)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
const detail = await this.audioPlayInfo.getStopDetail(exhibit.resolvedStopId || exhibit.id, { version: 'standard' })
|
||||||
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo)
|
return this.toExhibitFromStopDetail(detail, exhibit)
|
||||||
|
} catch {
|
||||||
if (options.includeText && playInfo.hasText) {
|
return exhibit
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,10 +256,7 @@ export class ExplainUseCase {
|
|||||||
return baseExhibit
|
return baseExhibit
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.enrichExhibitAudio(exhibit, {
|
return this.enrichExhibitAudio(this.applyTrackAudioState(exhibit, track))
|
||||||
includeText: options.includeText,
|
|
||||||
track
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
|
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
|
||||||
return exhibit
|
return exhibit
|
||||||
@@ -424,63 +271,30 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async enterExplainDetail(request: ExplainDetailEntryRequest) {
|
async enterExplainDetail(request: ExplainDetailEntryRequest) {
|
||||||
const entryTarget = await this.resolveDetailEntryTarget(request)
|
const stopId = this.resolveDetailStopId(request)
|
||||||
// Remote stop-info is the authoritative detail source. Never make its first paint wait for catalog.
|
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') {
|
if (dataSourceConfig.explainContentMode === 'remote') {
|
||||||
const stopInfo = await this.audioPlayInfo.getStopInfo(entryTarget)
|
return this.toExhibitFromStopDetail(detail, null, request, language)
|
||||||
return this.toExhibitFromStopInfo(stopInfo, null, request)
|
|
||||||
}
|
}
|
||||||
|
const fallbackExhibit = await this.resolveStaticDetailFallback(request, { stopId })
|
||||||
const fallbackExhibit = await this.resolveStaticDetailFallback(request, entryTarget)
|
return this.toExhibitFromStopDetail(detail, fallbackExhibit, request, language)
|
||||||
|
|
||||||
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('讲解详情加载失败')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
|
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)
|
const lang = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
|
const variants = exhibit.textVariants || []
|
||||||
try {
|
const version = exhibit.guideVersion || 'standard'
|
||||||
const textInfo = await this.audioPlayInfo.getTextInfo({
|
const variant = variants.find((item) => item.version === version && item.languageCode === lang)
|
||||||
targetType,
|
|| (lang === 'yue-HK' ? variants.find((item) => item.version === version && item.languageCode === 'zh-CN') : undefined)
|
||||||
targetId,
|
const text = variant?.text || (lang === 'yue-HK' ? exhibit.textVariants?.find((item) => item.languageCode === 'zh-CN')?.text : undefined)
|
||||||
lang
|
const nextExhibit = text ? { ...exhibit, guideText: text, audioText: text, audioHasText: true } : exhibit
|
||||||
})
|
return {
|
||||||
const nextExhibit = this.applyTextInfo(exhibit, textInfo)
|
exhibit: nextExhibit,
|
||||||
|
textInfo: { available: Boolean(text), lang, text, textLength: variant?.textLength, textHash: variant?.textHash },
|
||||||
return {
|
available: Boolean(text),
|
||||||
exhibit: nextExhibit,
|
unavailableMessage: text ? undefined : audioReasonToText('NO_TEXT')
|
||||||
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: '讲解词服务暂不可用,请稍后重试'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,11 +368,9 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openGuideStopDetail(stopId: string) {
|
openGuideStopDetail(stopId: string) {
|
||||||
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
|
|
||||||
return this.enterExplainDetail({
|
return this.enterExplainDetail({
|
||||||
exhibitId: target.targetId,
|
exhibitId: stopId,
|
||||||
targetType: target.targetType,
|
stopId
|
||||||
targetId: target.targetId
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,86 +390,40 @@ export class ExplainUseCase {
|
|||||||
const exhibit = detailExhibit || summaryExhibit
|
const exhibit = detailExhibit || summaryExhibit
|
||||||
if (!exhibit) return null
|
if (!exhibit) return null
|
||||||
|
|
||||||
const targetType: AudioPlayTargetType = track?.playTargetType
|
const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
|| exhibit.playTargetType
|
const option = resolveGuideAudioOption(exhibit.audioOptions, language, 'female')
|
||||||
|| 'ITEM'
|
if (!option?.playUrl) {
|
||||||
const targetId = track?.playTargetId
|
return { exhibit, track, media: null, playable: false, unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO') }
|
||||||
|| exhibit.playTargetId
|
|
||||||
|| exhibit.id
|
|
||||||
|
|
||||||
const toPlayableSelection = (
|
|
||||||
playInfo: AudioPlayInfo,
|
|
||||||
requestTargetType: AudioPlayTargetType,
|
|
||||||
requestTargetId: string
|
|
||||||
): ExplainAudioSelection => {
|
|
||||||
const media: MediaAsset = {
|
|
||||||
id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`,
|
|
||||||
type: 'audio',
|
|
||||||
url: playInfo.playUrl || undefined,
|
|
||||||
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
|
|
||||||
language: playInfo.lang,
|
|
||||||
available: true
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
exhibit,
|
|
||||||
track,
|
|
||||||
media,
|
|
||||||
playable: true,
|
|
||||||
playInfo
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
try {
|
exhibit,
|
||||||
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
track,
|
||||||
|
media: {
|
||||||
if (initialPlayInfo.playable && initialPlayInfo.playUrl) {
|
id: `channel-${option.channelCode}`,
|
||||||
return toPlayableSelection(initialPlayInfo, targetType, targetId)
|
type: 'audio',
|
||||||
}
|
url: option.playUrl,
|
||||||
|
duration: option.duration,
|
||||||
return {
|
language,
|
||||||
exhibit,
|
available: true
|
||||||
track,
|
},
|
||||||
media: null,
|
playable: true
|
||||||
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: '语音服务暂不可用,当前提供图文讲解'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectAudioForExplainDetail(
|
async selectAudioForExplainDetail(
|
||||||
exhibit: MuseumExhibit,
|
exhibit: MuseumExhibit,
|
||||||
options: {
|
options: {
|
||||||
refreshPlayInfo?: boolean
|
|
||||||
voiceGender?: GuideAudioGender
|
voiceGender?: GuideAudioGender
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<ExplainAudioSelection> {
|
): Promise<ExplainAudioSelection> {
|
||||||
const targetType = exhibit.playTargetType || 'ITEM'
|
|
||||||
const targetId = exhibit.playTargetId || exhibit.id
|
|
||||||
const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
const audioOption = options.refreshPlayInfo
|
const version = exhibit.guideVersion || 'standard'
|
||||||
? undefined
|
const optionsForVersion = (exhibit.audioOptions || []).filter((option) => !option.version || option.version === version)
|
||||||
: resolveGuideAudioOption(exhibit.audioOptions, language, options.voiceGender || 'male')
|
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) {
|
if (audioOption) {
|
||||||
const audioExhibit: MuseumExhibit = {
|
const audioExhibit: MuseumExhibit = {
|
||||||
@@ -683,12 +449,12 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.refreshPlayInfo && exhibit.audioUrl?.trim()) {
|
if (exhibit.audioUrl?.trim()) {
|
||||||
return {
|
return {
|
||||||
exhibit,
|
exhibit,
|
||||||
track: null,
|
track: null,
|
||||||
media: {
|
media: {
|
||||||
id: `static-${targetType}-${targetId}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
|
id: `static-${exhibit.resolvedStopId || exhibit.id}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
|
||||||
type: 'audio',
|
type: 'audio',
|
||||||
url: exhibit.audioUrl,
|
url: exhibit.audioUrl,
|
||||||
duration: exhibit.audioDuration,
|
duration: exhibit.audioDuration,
|
||||||
@@ -699,58 +465,12 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exhibit.audioStatus && exhibit.audioStatus !== 'READY') {
|
return {
|
||||||
return {
|
exhibit,
|
||||||
exhibit,
|
track: null,
|
||||||
track: null,
|
media: null,
|
||||||
media: null,
|
playable: false,
|
||||||
playable: false,
|
unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO')
|
||||||
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: '语音服务暂不可用,请稍后重试'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface PoiDetailTarget {
|
|||||||
detailId: string
|
detailId: string
|
||||||
hallId?: string
|
hallId?: string
|
||||||
exhibitId?: string
|
exhibitId?: string
|
||||||
|
stopId?: string
|
||||||
url: string
|
url: string
|
||||||
failureMessage: string
|
failureMessage: string
|
||||||
}
|
}
|
||||||
@@ -114,14 +115,24 @@ const resolveHallByControlledNameFallback = (
|
|||||||
export class PoiDetailUseCase {
|
export class PoiDetailUseCase {
|
||||||
async resolve(poi: PoiDetailSourcePoi): Promise<PoiDetailTarget> {
|
async resolve(poi: PoiDetailSourcePoi): Promise<PoiDetailTarget> {
|
||||||
if (poi.exhibitId) {
|
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 {
|
return {
|
||||||
poiId: poi.id,
|
poiId: poi.id,
|
||||||
floorId: poi.floorId,
|
floorId: poi.floorId,
|
||||||
detailType: 'exhibit',
|
detailType: 'exhibit',
|
||||||
detailAvailable: true,
|
detailAvailable: true,
|
||||||
detailId: poi.exhibitId,
|
detailId: stopId || poi.exhibitId,
|
||||||
exhibitId: 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: '展品详情打开失败,请重试'
|
failureMessage: '展品详情打开失败,请重试'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,6 @@ import {
|
|||||||
|
|
||||||
const isAbsoluteHttpUrl = (url: string) => /^https?:\/\//i.test(url)
|
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 resolveSdkMinioProxyBaseUrl = () => {
|
||||||
const apiBaseUrl = dataSourceConfig.apiBaseUrl.trim().replace(/\/+$/, '')
|
const apiBaseUrl = dataSourceConfig.apiBaseUrl.trim().replace(/\/+$/, '')
|
||||||
const normalizedApiBaseUrl = apiBaseUrl.endsWith('/app-api')
|
const normalizedApiBaseUrl = apiBaseUrl.endsWith('/app-api')
|
||||||
@@ -50,11 +32,8 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value)
|
const parsed = new URL(value)
|
||||||
const isSameServer = configuredSameOriginAssetHosts().includes(parsed.hostname)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isSameServer
|
parsed.port === '9000'
|
||||||
&& parsed.port === '9000'
|
|
||||||
&& (
|
&& (
|
||||||
parsed.pathname.startsWith('/museum-assets/')
|
parsed.pathname.startsWith('/museum-assets/')
|
||||||
|| parsed.pathname.startsWith('/tts-audio/')
|
|| parsed.pathname.startsWith('/tts-audio/')
|
||||||
@@ -64,16 +43,14 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isSameServer
|
parsed.port === '48080'
|
||||||
&& parsed.port === '48080'
|
|
||||||
&& parsed.pathname.startsWith('/yudao-server/')
|
&& parsed.pathname.startsWith('/yudao-server/')
|
||||||
) {
|
) {
|
||||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
configuredLegacyAudioHosts().includes(parsed.hostname)
|
parsed.port === '19000'
|
||||||
&& parsed.port === '19000'
|
|
||||||
&& parsed.pathname.startsWith('/nhm/audio/')
|
&& parsed.pathname.startsWith('/nhm/audio/')
|
||||||
) {
|
) {
|
||||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export interface ExplainDetailPageViewModel {
|
|||||||
supportedLanguages?: string[]
|
supportedLanguages?: string[]
|
||||||
languageLabel?: string
|
languageLabel?: string
|
||||||
audioOptions?: MuseumGuideAudioOption[]
|
audioOptions?: MuseumGuideAudioOption[]
|
||||||
|
textVariants?: import('@/domain/museum').MuseumGuideTextVariant[]
|
||||||
|
recommendedTrackCode?: string
|
||||||
|
version?: 'standard' | 'extended'
|
||||||
|
stopId?: string
|
||||||
}
|
}
|
||||||
imageStatus?: string
|
imageStatus?: string
|
||||||
galleryStatusText?: string
|
galleryStatusText?: string
|
||||||
@@ -238,7 +242,11 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
|||||||
statusText: hasPlayableAudio ? '可播放' : '当前语言暂无语音讲解',
|
statusText: hasPlayableAudio ? '可播放' : '当前语言暂无语音讲解',
|
||||||
supportedLanguages: exhibit.supportedLanguages,
|
supportedLanguages: exhibit.supportedLanguages,
|
||||||
languageLabel: languageLabelFor(exhibit.audioLanguage),
|
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,
|
imageStatus: exhibit.imageStatus,
|
||||||
galleryStatusText: coverImages.length > 1 ? `共 ${coverImages.length} 张讲解图片` : undefined,
|
galleryStatusText: coverImages.length > 1 ? `共 ${coverImages.length} 张讲解图片` : undefined,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ test('hall goes directly to paged guide objects without outline requests', async
|
|||||||
await page.mouse.wheel(0, 10000)
|
await page.mouse.wheel(0, 10000)
|
||||||
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
|
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
|
||||||
await page.getByText('讲解对象1', { exact: true }).click()
|
await page.getByText('讲解对象1', { exact: true }).click()
|
||||||
await expect(page).toHaveURL(/pages\/exhibit\/detail\?.*targetType=STOP.*targetId=target-1/)
|
await expect(page).toHaveURL(/pages\/exhibit\/detail\?.*stopId=stop-1/)
|
||||||
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
|
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
|
||||||
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
|
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,47 +1,81 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { DefaultAudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
|
import { DefaultAudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
|
||||||
|
|
||||||
const stopInfoResponse = {
|
const detailResponse = {
|
||||||
code: 0,
|
code: 0,
|
||||||
data: {
|
data: {
|
||||||
available: true,
|
id: '865546647764037632',
|
||||||
targetType: 'STOP',
|
version: 'standard',
|
||||||
targetId: 'stop-1',
|
name: '测试讲解点',
|
||||||
resolvedStopId: 'stop-1',
|
imageStatus: 'MISSING',
|
||||||
lang: 'zh-CN',
|
textVariants: [{ version: 'standard', languageCode: 'zh-CN', text: '测试正文' }],
|
||||||
title: '测试讲解点',
|
audioTracks: [{
|
||||||
description: '测试简介',
|
version: 'standard', languageCode: 'zh-CN', gender: 'female',
|
||||||
imageStatus: 'READY',
|
playUrl: '/museum-assets/audio/zh-female.mp3'
|
||||||
imageSource: 'STOP',
|
}],
|
||||||
galleryUrls: '[]',
|
|
||||||
playTargetType: 'STOP',
|
|
||||||
playTargetId: 'stop-1',
|
|
||||||
hasAudio: false,
|
|
||||||
hasText: true,
|
hasText: true,
|
||||||
supportedLanguages: ['zh-CN'],
|
hasAudio: true
|
||||||
audioStatus: 'MISSING',
|
|
||||||
audioOptions: []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('DefaultAudioPlayInfoRepository stop-info loading mode', () => {
|
describe('DefaultAudioPlayInfoRepository unified stop detail', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
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[] = []
|
const requestUrls: string[] = []
|
||||||
vi.stubGlobal('uni', {
|
vi.stubGlobal('uni', {
|
||||||
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
|
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
|
||||||
requestUrls.push(url)
|
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()
|
const repository = new DefaultAudioPlayInfoRepository()
|
||||||
|
|
||||||
await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN' })
|
const first = await repository.getStopDetail('865546647764037632')
|
||||||
await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN', lightweight: false })
|
const second = await repository.getStopDetail('865546647764037632')
|
||||||
|
const extended = await repository.getStopDetail('865546647764037632', { version: 'extended' })
|
||||||
|
|
||||||
expect(requestUrls).toHaveLength(2)
|
expect(first.id).toBe('865546647764037632')
|
||||||
expect(new URL(requestUrls[0], 'http://localhost').searchParams.get('lightweight')).toBe('true')
|
expect(second).toBe(first)
|
||||||
expect(new URL(requestUrls[1], 'http://localhost').searchParams.get('lightweight')).toBe('false')
|
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('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('导览讲解点不存在')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const installRequest = (handler: (url: string) => unknown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('BackendExplainContentProvider hall stop paging', () => {
|
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 () => {
|
it('uses the paged hall endpoint and maps independent pages without outlines', async () => {
|
||||||
const requests: string[] = []
|
const requests: string[] = []
|
||||||
@@ -23,8 +26,8 @@ describe('BackendExplainContentProvider hall stop paging', () => {
|
|||||||
data: {
|
data: {
|
||||||
total: 30,
|
total: 30,
|
||||||
list: pageNo === '1'
|
list: pageNo === '1'
|
||||||
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', playTargetType: 'STOP', playTargetId: 'target-1' }]
|
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', outlineId: 'outline-1', linkedExhibits: [] }]
|
||||||
: [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', playTargetType: 'STOP', playTargetId: 'target-2' }]
|
: [{ 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).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(second.items.map((item) => item.id)).toEqual(['stop-2'])
|
||||||
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(2)
|
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(2)
|
||||||
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
|
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('/catalog/halls?'))).toHaveLength(1)
|
||||||
expect(requests.filter((url) => url.includes('/stops/page'))).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 })
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.get('.detail-title').text()).toBe('Classical Astrolabe')
|
expect(wrapper.get('.detail-title').text()).toBe('Classical Astrolabe')
|
||||||
@@ -164,7 +164,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.get('.content-section').classes()).toContain('is-english')
|
expect(wrapper.get('.content-section').classes()).toContain('is-english')
|
||||||
expect(wrapper.find('.section-title').exists()).toBe(false)
|
expect(wrapper.find('.section-title').exists()).toBe(false)
|
||||||
expect(wrapper.get('.section-text').text()).toContain('detailed English narration')
|
expect(wrapper.get('.section-text').text()).toContain('detailed English narration')
|
||||||
expect(wrapper.get('.detail-audio-time').text()).toBe('0:00 / 1:21')
|
expect(wrapper.get('.detail-audio-time').text()).toBe('0:00 / 0:00')
|
||||||
expect(wrapper.get('.detail-audio-speed').attributes('aria-label')).toBe('播放速度 1.0倍')
|
expect(wrapper.get('.detail-audio-speed').attributes('aria-label')).toBe('播放速度 1.0倍')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const state = wrapper.get('[data-testid="exhibit-detail-state"]')
|
const state = wrapper.get('[data-testid="exhibit-detail-state"]')
|
||||||
@@ -197,7 +197,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const labels = wrapper.findAll('.language-option-text').map((node) => node.text())
|
const labels = wrapper.findAll('.language-option-text').map((node) => node.text())
|
||||||
@@ -214,7 +214,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
|
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
|
||||||
@@ -231,7 +231,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' }))
|
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' }))
|
||||||
@@ -246,7 +246,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await wrapper.get('.detail-audio-play').trigger('tap')
|
await wrapper.get('.detail-audio-play').trigger('tap')
|
||||||
|
|
||||||
@@ -258,13 +258,13 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
|
|
||||||
it('加载中使用同一高度底栏和不确定进度', async () => {
|
it('加载中使用同一高度底栏和不确定进度', async () => {
|
||||||
audioState.currentAudio.value = { id: 'audio-1' }
|
audioState.currentAudio.value = { id: 'audio-1' }
|
||||||
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
|
audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US' }
|
||||||
audioState.loading.value = true
|
audioState.loading.value = true
|
||||||
const wrapper = mount(ExhibitDetail, {
|
const wrapper = mount(ExhibitDetail, {
|
||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading')
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading')
|
||||||
@@ -287,7 +287,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-unavailable')
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-unavailable')
|
||||||
@@ -295,9 +295,9 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.find('.detail-audio-status').exists()).toBe(false)
|
expect(wrapper.find('.detail-audio-status').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('播放失败显示错误并通过既有刷新链路重试', async () => {
|
it('播放失败显示错误并从当前详情音轨重试', async () => {
|
||||||
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
|
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
|
||||||
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
|
audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US' }
|
||||||
audioState.error.value = '音频暂时无法播放'
|
audioState.error.value = '音频暂时无法播放'
|
||||||
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
||||||
playable: true,
|
playable: true,
|
||||||
@@ -309,14 +309,17 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-failed')
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-failed')
|
||||||
expect(wrapper.find('.detail-audio-retry').exists()).toBe(false)
|
expect(wrapper.find('.detail-audio-retry').exists()).toBe(false)
|
||||||
|
|
||||||
await wrapper.get('.detail-audio-play').trigger('tap')
|
await wrapper.get('.detail-audio-play').trigger('tap')
|
||||||
await flushPromises()
|
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({
|
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({
|
||||||
retryOnError: expect.any(Function),
|
retryOnError: expect.any(Function),
|
||||||
displayMode: 'mini'
|
displayMode: 'mini'
|
||||||
@@ -345,17 +348,17 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.get('.detail-audio-speed').text()).toBe('1.0')
|
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-speed').trigger('tap')
|
||||||
await wrapper.get('.detail-audio-voice').trigger('tap')
|
await wrapper.get('.detail-audio-voice').trigger('tap')
|
||||||
|
|
||||||
expect(mocks.setPlaybackRate).toHaveBeenCalledWith(1.25)
|
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()
|
expect(mocks.toggleMute).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -377,12 +380,12 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
|
|
||||||
it('离开详情页时立即关闭当前讲解播放器', async () => {
|
it('离开详情页时立即关闭当前讲解播放器', async () => {
|
||||||
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
|
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
|
||||||
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
|
audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US' }
|
||||||
const wrapper = mount(ExhibitDetail, {
|
const wrapper = mount(ExhibitDetail, {
|
||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
|
|
||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
mocks.onHideHandler?.()
|
mocks.onHideHandler?.()
|
||||||
@@ -400,16 +403,12 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
await mocks.onLoadHandler?.({
|
await mocks.onLoadHandler?.({
|
||||||
id: exhibit.id,
|
id: exhibit.id,
|
||||||
lang: 'en-US',
|
lang: 'en-US',
|
||||||
targetType: 'STOP',
|
stopId: 'stop-1',
|
||||||
targetId: 'stop-1',
|
|
||||||
hallId: 'hall-1',
|
hallId: 'hall-1',
|
||||||
hallName: '%2525E6%252581%252590%2525E9%2525BE%252599%2525E5%25258E%252585'
|
hallName: '%2525E6%252581%252590%2525E9%2525BE%252599%2525E5%25258E%252585'
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => {
|
|
||||||
options.fail?.()
|
|
||||||
})
|
|
||||||
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
|
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -418,6 +417,6 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
|
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
|
||||||
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(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
|
expect(uni.navigateBack).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('ExplainGuideStopCatalog', () => {
|
|||||||
expect(wrapper.findAll('.stop-status')).toHaveLength(3)
|
expect(wrapper.findAll('.stop-status')).toHaveLength(3)
|
||||||
expect(wrapper.text()).toContain('讲解')
|
expect(wrapper.text()).toContain('讲解')
|
||||||
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('.hall-arrow').exists()).toBe(false)
|
||||||
expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
|
expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -110,8 +110,7 @@ describe('讲解对象列表返回', () => {
|
|||||||
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('stopId')).toBe('stop-1')
|
||||||
expect(params.get('targetId')).toBe('target-1')
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +1,89 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
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 { AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
|
||||||
import type { ExplainRepository } from '@/repositories/ExplainRepository'
|
import type { ExplainRepository } from '@/repositories/ExplainRepository'
|
||||||
import { ExplainUseCase } from '@/usecases/explainUseCase'
|
import { ExplainUseCase } from '@/usecases/explainUseCase'
|
||||||
import { dataSourceConfig } from '@/config/dataSource'
|
import { dataSourceConfig } from '@/config/dataSource'
|
||||||
|
|
||||||
const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo => ({
|
const createDetail = (overrides: Partial<GuideStopDetail> = {}): GuideStopDetail => ({
|
||||||
available: true,
|
id: 'stop-1',
|
||||||
targetType: 'STOP',
|
version: 'standard',
|
||||||
targetId: 'stop-1',
|
name: 'Guide stop',
|
||||||
lang: 'zh-CN',
|
|
||||||
title: 'Guide stop',
|
|
||||||
galleryUrls: [],
|
galleryUrls: [],
|
||||||
imageStatus: 'MISSING',
|
imageStatus: 'MISSING',
|
||||||
linkedExhibits: [
|
linkedExhibits: [{ id: 'exhibit-1', name: 'Linked exhibit', coverImageUrl: '/linked-exhibit.jpg' }],
|
||||||
{
|
isSharedStop: false,
|
||||||
id: 'exhibit-1',
|
textVariants: [
|
||||||
name: 'Linked exhibit',
|
{ version: 'standard', languageCode: 'zh-CN', text: '普通话正文' },
|
||||||
coverImageUrl: '/linked-exhibit.jpg'
|
{ version: 'standard', languageCode: 'en-US', text: 'English text' }
|
||||||
}
|
|
||||||
],
|
],
|
||||||
playTargetType: 'STOP',
|
audioTracks: [
|
||||||
playTargetId: 'stop-1',
|
{ channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' },
|
||||||
hasAudio: false,
|
{ channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/yue-female.mp3' }
|
||||||
hasText: false,
|
],
|
||||||
supportedLanguages: [],
|
hasText: true,
|
||||||
audioStatus: 'MISSING',
|
hasAudio: true,
|
||||||
|
textVariantCount: 2,
|
||||||
|
audioTrackCount: 2,
|
||||||
...overrides
|
...overrides
|
||||||
})
|
})
|
||||||
|
|
||||||
const createUseCase = (stopInfo: GuideStopInfo) => {
|
const createUseCase = (detail: GuideStopDetail) => {
|
||||||
const explain = {
|
const explain = {
|
||||||
getExhibitById: vi.fn().mockResolvedValue(null),
|
getExhibitById: vi.fn().mockResolvedValue(null),
|
||||||
listExplainExhibits: vi.fn().mockResolvedValue([])
|
listExplainExhibits: vi.fn().mockResolvedValue([])
|
||||||
} as unknown as ExplainRepository
|
} as unknown as ExplainRepository
|
||||||
const audioPlayInfo = {
|
const audio = { getStopDetail: vi.fn().mockResolvedValue(detail) } as unknown as AudioPlayInfoRepository
|
||||||
getStopInfo: vi.fn().mockResolvedValue(stopInfo)
|
return { useCase: new ExplainUseCase(explain, audio), explain, audio }
|
||||||
} as unknown as AudioPlayInfoRepository
|
|
||||||
|
|
||||||
return new ExplainUseCase(explain, audioPlayInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ExplainUseCase stop image policy', () => {
|
describe('ExplainUseCase unified stop detail', () => {
|
||||||
it('uses stop-info first in remote mode without requesting a catalog fallback', async () => {
|
it('uses stopId once in remote mode without requesting a catalog fallback', async () => {
|
||||||
const originalMode = dataSourceConfig.explainContentMode
|
const originalMode = dataSourceConfig.explainContentMode
|
||||||
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })
|
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })
|
||||||
const explain = {
|
const { useCase, explain, audio } = createUseCase(createDetail())
|
||||||
getExhibitById: vi.fn(),
|
|
||||||
listExplainExhibits: vi.fn(),
|
|
||||||
listExplainExhibitsByHall: vi.fn()
|
|
||||||
} as unknown as ExplainRepository
|
|
||||||
const audio = { getStopInfo: vi.fn().mockResolvedValue(createStopInfo()) } as unknown as AudioPlayInfoRepository
|
|
||||||
|
|
||||||
await new ExplainUseCase(explain, audio).enterExplainDetail({
|
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', hallName: '宇宙厅' })
|
||||||
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', hallName: '宇宙厅'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(audio.getStopInfo).toHaveBeenCalledTimes(1)
|
expect(audio.getStopDetail).toHaveBeenCalledWith('stop-1', { version: 'standard' })
|
||||||
expect(explain.getExhibitById).not.toHaveBeenCalled()
|
expect(explain.getExhibitById).not.toHaveBeenCalled()
|
||||||
expect(explain.listExplainExhibits).not.toHaveBeenCalled()
|
expect(result.hallName).toBe('宇宙厅')
|
||||||
Object.assign(dataSourceConfig, { explainContentMode: originalMode })
|
Object.assign(dataSourceConfig, { explainContentMode: originalMode })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('delegates hall exhibits and business-unit stops without loading full exhibits', async () => {
|
it('does not use linked exhibit images when detail reports MISSING', async () => {
|
||||||
const explain = {
|
const { useCase } = createUseCase(createDetail())
|
||||||
listExplainExhibitsByHall: vi.fn().mockResolvedValue([]),
|
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' })
|
||||||
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'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(detail.image).toBeUndefined()
|
expect(result.image).toBeUndefined()
|
||||||
expect(detail.galleryUrls).toEqual([])
|
expect(result.galleryUrls).toEqual([])
|
||||||
expect(detail.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
|
expect(result.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the stop image when stop-info reports READY', async () => {
|
it('uses local standard text and audio variants, with Chinese text fallback for Cantonese', async () => {
|
||||||
const detail = await createUseCase(createStopInfo({
|
const { useCase, audio } = createUseCase(createDetail())
|
||||||
imageStatus: 'READY',
|
const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', lang: 'yue-HK' })
|
||||||
coverImageUrl: '/guide-stop.jpg',
|
const text = await useCase.loadExplainDetailText(result)
|
||||||
galleryUrls: ['/guide-stop-gallery.jpg']
|
const selection = await useCase.selectAudioForExplainDetail(result, { voiceGender: 'male' })
|
||||||
})).enterExplainDetail({
|
|
||||||
exhibitId: 'stop-1',
|
|
||||||
targetType: 'STOP',
|
|
||||||
targetId: 'stop-1'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(detail.image).toBe('/guide-stop.jpg')
|
expect(result.guideText).toBe('普通话正文')
|
||||||
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
|
expect(text.textInfo.text).toBe('普通话正文')
|
||||||
|
expect(selection.media?.url).toBe('/yue-female.mp3')
|
||||||
|
expect(audio.getStopDetail).toHaveBeenCalledTimes(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' }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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'
|
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
|
||||||
|
|
||||||
describe('全局讲解播放器语言切换', () => {
|
describe('全局讲解播放器语言切换', () => {
|
||||||
@@ -20,10 +8,7 @@ describe('全局讲解播放器语言切换', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
player.close()
|
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, 'play').mockResolvedValue(undefined)
|
||||||
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
|
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
|
||||||
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined)
|
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined)
|
||||||
@@ -35,18 +20,9 @@ describe('全局讲解播放器语言切换', () => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => {
|
it('切换到粤语时只从当前统一详情的音轨数组选择,不产生额外 HTTP 请求', async () => {
|
||||||
repositoryMocks.getPlayInfo.mockResolvedValue({
|
const request = vi.fn()
|
||||||
playable: true,
|
vi.stubGlobal('uni', { request, showToast: vi.fn() })
|
||||||
targetType: 'STOP',
|
|
||||||
targetId: '1823450596800289',
|
|
||||||
lang: 'yue-HK',
|
|
||||||
audioId: 'cantonese-audio',
|
|
||||||
playUrl: '/museum-assets/audio/cantonese.mp3',
|
|
||||||
hasText: true,
|
|
||||||
fallback: false
|
|
||||||
})
|
|
||||||
|
|
||||||
await player.play({
|
await player.play({
|
||||||
id: 'mandarin-audio',
|
id: 'mandarin-audio',
|
||||||
name: '测试讲解',
|
name: '测试讲解',
|
||||||
@@ -54,28 +30,25 @@ describe('全局讲解播放器语言切换', () => {
|
|||||||
language: 'zh-CN'
|
language: 'zh-CN'
|
||||||
}, {
|
}, {
|
||||||
source: {
|
source: {
|
||||||
targetType: 'STOP',
|
stopId: '1823450596800289',
|
||||||
targetId: '1823450596800289',
|
lang: 'zh-CN',
|
||||||
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')
|
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)
|
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({
|
expect(player.currentAudio.value).toEqual(expect.objectContaining({
|
||||||
language: 'yue-HK',
|
language: 'yue-HK',
|
||||||
audioUrl: '/museum-assets/audio/cantonese.mp3'
|
audioUrl: '/museum-assets/audio/cantonese.mp3'
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
|
||||||
toGuideStopInfo
|
|
||||||
} from '@/data/adapters/guideStopInfoAdapter'
|
|
||||||
import {
|
import {
|
||||||
canToggleGuideAudioGender,
|
canToggleGuideAudioGender,
|
||||||
GUIDE_AUDIO_LANGUAGE_OPTIONS,
|
GUIDE_AUDIO_LANGUAGE_OPTIONS,
|
||||||
@@ -46,36 +43,6 @@ const audioOptions: MuseumGuideAudioOption[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
describe('guide audio options', () => {
|
describe('guide audio options', () => {
|
||||||
it('parses directly playable audio options from stop-info', () => {
|
|
||||||
const stopInfo = toGuideStopInfo({
|
|
||||||
available: true,
|
|
||||||
targetType: 'STOP',
|
|
||||||
targetId: '100',
|
|
||||||
lang: 'zh-CN',
|
|
||||||
title: '测试讲解',
|
|
||||||
audioStatus: 'READY',
|
|
||||||
audioOptionCount: 5,
|
|
||||||
audioOptions: audioOptions.map((option) => ({
|
|
||||||
...option,
|
|
||||||
duration: 120
|
|
||||||
}))
|
|
||||||
}, {
|
|
||||||
targetType: 'STOP',
|
|
||||||
targetId: '100',
|
|
||||||
lang: 'zh-CN'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(stopInfo.audioOptions).toHaveLength(5)
|
|
||||||
expect(stopInfo.audioOptions[1]).toMatchObject({
|
|
||||||
channelCode: 'standard.zh-CN.male',
|
|
||||||
languageCode: 'zh-CN',
|
|
||||||
gender: 'male',
|
|
||||||
playUrl: '/audio/zh-male.mp3',
|
|
||||||
duration: 120
|
|
||||||
})
|
|
||||||
expect(stopInfo.supportedLanguages).toEqual(['zh-CN', 'en-US', 'yue-HK'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps tabs in Chinese, English, Cantonese order and defaults Chinese and English to male', () => {
|
it('keeps tabs in Chinese, English, Cantonese order and defaults Chinese and English to male', () => {
|
||||||
expect(GUIDE_AUDIO_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual([
|
expect(GUIDE_AUDIO_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual([
|
||||||
'zh-CN',
|
'zh-CN',
|
||||||
|
|||||||
@@ -1,137 +1,67 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
|
import { toGuideStopDetail } from '@/data/adapters/guideStopInfoAdapter'
|
||||||
|
|
||||||
describe('guide stop-info adapter', () => {
|
const languages = ['zh-CN', 'en-US', 'yue-HK'] as const
|
||||||
it('normalizes aggregated language variants and exposes only displayable languages', () => {
|
|
||||||
const result = toGuideStopInfo({
|
describe('unified guide stop detail adapter', () => {
|
||||||
available: true,
|
it('preserves snowflake IDs, synthesizes stable track codes, and ignores images marked missing', () => {
|
||||||
targetType: 'STOP',
|
const textVariants = languages.map((languageCode) => ({
|
||||||
targetId: '9001',
|
version: 'standard',
|
||||||
lang: 'zh-CN',
|
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',
|
imageStatus: 'READY',
|
||||||
coverImageUrl: '/museum-assets/cover.webp',
|
textVariants: [{ version: 'extended', languageCode: 'zh-CN', text: '普通话正文' }],
|
||||||
languageVariants: [
|
audioTracks: [
|
||||||
{
|
{ version: 'extended', languageCode: 'yue-HK', gender: 'female', playUrl: '/cantonese.mp3' },
|
||||||
lang: 'zh',
|
{ version: 'extended', languageCode: 'zh-CN', gender: 'female' }
|
||||||
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(detail.version).toBe('extended')
|
||||||
expect(result.languageVariants['zh-CN']).toMatchObject({
|
expect(detail.textVariants[0]?.text).toBe('普通话正文')
|
||||||
playable: true,
|
expect(detail.audioTracks).toEqual([expect.objectContaining({
|
||||||
playUrl: '/museum-assets/audio/zh.mp3',
|
channelCode: 'extended.yue-HK.female',
|
||||||
duration: 50,
|
gender: 'female'
|
||||||
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',
|
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
37
tests/unit/PoiDetailUseCase.spec.ts
Normal file
37
tests/unit/PoiDetailUseCase.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,20 @@ describe('normalizeSameOriginPublicUrl', () => {
|
|||||||
)).toBe('/app-api/gis/sdk/minio/museum-assets/guide-final/E2/detail.jpg')
|
)).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', () => {
|
it('keeps ordinary relative paths unchanged', () => {
|
||||||
expect(normalizeSameOriginPublicUrl('/static/placeholder.png'))
|
expect(normalizeSameOriginPublicUrl('/static/placeholder.png'))
|
||||||
.toBe('/static/placeholder.png')
|
.toBe('/static/placeholder.png')
|
||||||
|
|||||||
Reference in New Issue
Block a user