Compare commits
6 Commits
735c5cd1ee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d07d68af0a | |||
| 5d0c38e726 | |||
| 4d8cc55a5c | |||
|
|
dd29ead041 | ||
|
|
b90c58451c | ||
|
|
1f89e8b3e0 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
- name: Build H5
|
- name: Build H5
|
||||||
run: pnpm build:h5
|
run: pnpm build:test:h5
|
||||||
|
|
||||||
- name: Build WeChat Mini Program
|
- name: Build WeChat Mini Program
|
||||||
run: pnpm build:mp-weixin
|
run: pnpm build:mp-weixin
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# frontend-miniapp 正式环境部署手册
|
# frontend-miniapp 正式环境部署手册
|
||||||
|
|
||||||
最后更新:2026-07-15
|
最后更新:2026-07-21
|
||||||
|
|
||||||
## 适用范围
|
## 适用范围
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ git remote -v
|
|||||||
正式环境当前依赖一组本地生产补丁,拉取远程代码前必须保护。常见补丁文件包括:
|
正式环境当前依赖一组本地生产补丁,拉取远程代码前必须保护。常见补丁文件包括:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
.env.development
|
||||||
.env.production
|
.env.production
|
||||||
src/config/dataSource.ts
|
src/config/dataSource.ts
|
||||||
src/data/adapters/guideDataAdapter.ts
|
src/data/adapters/guideDataAdapter.ts
|
||||||
@@ -49,19 +50,34 @@ static/guide-data/guide-stops.json
|
|||||||
static/guide-data/manifest.json
|
static/guide-data/manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
这些文件中的改动属于正式环境本地补丁,拉取远程代码时不要直接丢弃。当前正式环境相关配置要点:
|
||||||
|
|
||||||
|
- `VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST=172.20.14.21`,避免正式包混入测试服务器 `1.92.206.90`。
|
||||||
|
- `VITE_EXPLAIN_ALLOW_STATIC_FALLBACK=true`,讲解数据接口异常时允许使用静态兜底数据。
|
||||||
|
- `VITE_SGS_SDK_ORIGIN=https://guide.sznhmuseum.org.cn`,正式域名不再使用 `:4430`。
|
||||||
|
- `static/guide-data/manifest.json` 中 `sourceHost` 应为 `172.20.14.21`。
|
||||||
|
- 腾讯地图正式 key 只通过环境变量 `VITE_TENCENT_MAP_KEY` 注入,不能写入文档或提交记录。
|
||||||
|
|
||||||
## 拉取远程最新代码
|
## 拉取远程最新代码
|
||||||
|
|
||||||
先临时保存本地生产补丁:
|
先临时保存本地生产补丁:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$stamp = Get-Date -Format 'yyyyMMddHHmmss'
|
$stamp = Get-Date -Format 'yyyyMMddHHmmss'
|
||||||
$msg = "pre-deploy-local-prod-fixes-$stamp"
|
$msg = "pre-deploy-prod-local-fixes-$stamp"
|
||||||
if (git status --porcelain) {
|
if (git status --porcelain) {
|
||||||
git stash push -u -m $msg
|
git stash push -u -m $msg
|
||||||
Write-Output "STASHED=$msg"
|
Write-Output "STASHED=$msg"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
执行成功时会看到类似输出,表示本地补丁已 stash:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Saved working directory and index state On master: pre-deploy-prod-local-fixes-YYYYMMDDHHmmss
|
||||||
|
STASHED=pre-deploy-prod-local-fixes-YYYYMMDDHHmmss
|
||||||
|
```
|
||||||
|
|
||||||
拉取远程最新代码:
|
拉取远程最新代码:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -83,6 +99,8 @@ git status --short --branch --untracked-files=all
|
|||||||
|
|
||||||
如出现冲突,先解决冲突并重新确认生产配置,再继续构建。
|
如出现冲突,先解决冲突并重新确认生产配置,再继续构建。
|
||||||
|
|
||||||
|
恢复后重点检查 `.env.production`、`static/guide-data/manifest.json` 和 `src/utils/publicUrl.ts`,确认正式环境仍指向 `172.20.14.21` 和 `https://guide.sznhmuseum.org.cn`。
|
||||||
|
|
||||||
## 本地构建
|
## 本地构建
|
||||||
|
|
||||||
安装依赖并做类型检查:
|
安装依赖并做类型检查:
|
||||||
@@ -447,3 +465,4 @@ https://guide.sznhmuseum.org.cn/ HTTP 200
|
|||||||
- 2026-07-13:确认 `pnpm build:h5` 在 terser 阶段可能 OOM,正式部署可使用 `build:h5:no-minify` 兜底。
|
- 2026-07-13:确认 `pnpm build:h5` 在 terser 阶段可能 OOM,正式部署可使用 `build:h5:no-minify` 兜底。
|
||||||
- 2026-07-13:新增并验证 `static/icons/poi/shortcut-icons.svg` 线上可访问。
|
- 2026-07-13:新增并验证 `static/icons/poi/shortcut-icons.svg` 线上可访问。
|
||||||
- 2026-07-15:排查并修复页面 200 但空白问题。根因为 PowerShell 手工替换构建 JS 时破坏 UTF-8,已新增 `scripts/finalize-h5-build.cjs` 统一处理资源复制、腾讯地图 key 替换、JS 语法校验和正式环境字符串扫描。
|
- 2026-07-15:排查并修复页面 200 但空白问题。根因为 PowerShell 手工替换构建 JS 时破坏 UTF-8,已新增 `scripts/finalize-h5-build.cjs` 统一处理资源复制、腾讯地图 key 替换、JS 语法校验和正式环境字符串扫描。
|
||||||
|
- 2026-07-20:拉取远程最新代码前已执行 `git stash push -u -m pre-deploy-prod-local-fixes-20260720232941`,确认“本地补丁已 stash”;随后快进到 `735c5cd`,执行 `git stash pop` 恢复正式环境补丁,无冲突。构建入口为 `assets/index-CsFOrYfU.js`,主节点备份为 `/data/nginx/html/_backups/mobile-before-deploy-20260720233343.tar.gz`,同步脚本最终输出 `All frontend worker nodes synced.`。
|
||||||
|
|||||||
718
docs/deployment/guide-whaoyue-nginx-ssl.md
Normal file
718
docs/deployment/guide-whaoyue-nginx-ssl.md
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
# guide.whaoyue.com 静态站点部署说明
|
||||||
|
|
||||||
|
## 1. 部署目标
|
||||||
|
|
||||||
|
- 域名:`guide.whaoyue.com`
|
||||||
|
- 服务器备注:自然博物馆-测试服务器
|
||||||
|
- 公网 IP:`1.92.206.90`
|
||||||
|
- SSH 用户:`root`
|
||||||
|
- 系统:Huawei Cloud EulerOS 2.0 aarch64
|
||||||
|
- 部署内容:Nginx 静态测试页面 + Let’s Encrypt HTTPS 证书
|
||||||
|
- 页面地址:<https://guide.whaoyue.com:4433/>
|
||||||
|
- 首次部署日期:2026-06-08
|
||||||
|
- 端口调整日期:2026-07-22
|
||||||
|
|
||||||
|
> 当前公网 HTTPS 入口是 `4433`,访问地址必须显式携带端口。`https://guide.whaoyue.com/` 使用默认 `443`,不再是当前入口。
|
||||||
|
|
||||||
|
## 2. 当前部署拓扑
|
||||||
|
|
||||||
|
服务器已安装系统级 Nginx 包。Docker Nginx 容器内仍监听标准 `80/443`,宿主机公网映射已调整为 `8888/4433`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
公网用户
|
||||||
|
-> guide.whaoyue.com
|
||||||
|
-> 1.92.206.90:8888/4433
|
||||||
|
-> Docker 容器 nginx-server
|
||||||
|
-> 宿主机 8888 -> 容器 80
|
||||||
|
-> 宿主机 4433 -> 容器 443
|
||||||
|
-> /dmdata/nginx/html/guide/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Nginx 容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
当前端口映射:
|
||||||
|
|
||||||
|
| 宿主机端口 | 容器端口 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `8888` | `80` | HTTP 入口,跳转到 `https://guide.whaoyue.com:4433/` |
|
||||||
|
| `4433` | `443` | HTTPS 静态站点和反向代理 |
|
||||||
|
| `7080` | `7080` | 既有服务,端口调整时必须保留 |
|
||||||
|
| `7081` | `7081` | 既有服务,端口调整时必须保留 |
|
||||||
|
|
||||||
|
关键挂载:
|
||||||
|
|
||||||
|
| 宿主机路径 | 容器路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/dmdata/nginx/conf.d` | `/etc/nginx/conf.d` | Nginx 站点配置 |
|
||||||
|
| `/dmdata/nginx/html` | `/usr/share/nginx/html` | 静态页面 |
|
||||||
|
| `/dmdata/nginx/ssl` | `/etc/nginx/ssl` | SSL 证书 |
|
||||||
|
| `/dmdata/nginx/logs` | `/var/log/nginx` | 访问日志和错误日志 |
|
||||||
|
|
||||||
|
## 3. 静态页面
|
||||||
|
|
||||||
|
静态测试页路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/dmdata/nginx/html/guide/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
页面用途:
|
||||||
|
|
||||||
|
- 验证域名解析
|
||||||
|
- 验证 Nginx 静态站点
|
||||||
|
- 验证 HTTP 到 HTTPS 跳转
|
||||||
|
- 验证 Let’s Encrypt 证书
|
||||||
|
|
||||||
|
如需替换正式页面,将构建后的静态文件发布到:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/dmdata/nginx/html/guide/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Nginx 配置
|
||||||
|
|
||||||
|
站点配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/dmdata/nginx/conf.d/guide.whaoyue.com.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
配置行为:
|
||||||
|
|
||||||
|
- `http://guide.whaoyue.com:8888/` 自动跳转到 `https://guide.whaoyue.com:4433/`
|
||||||
|
- `/.well-known/acme-challenge/` 路径仍保留,但 Let’s Encrypt HTTP-01 不会验证 `8888`,续期限制见第 5 节
|
||||||
|
- `https://guide.whaoyue.com:4433/` 返回静态页面
|
||||||
|
- `https://guide.whaoyue.com:4433/app-api/` 反向代理到宿主机 `48080/yudao-server/app-api/`
|
||||||
|
- `https://guide.whaoyue.com:4433/h5-sdk` 配置为反向代理到宿主机 `3001`;2026-07-22 公网抽查为 `502`,当前不可作为可用能力
|
||||||
|
- `https://guide.whaoyue.com:4433/museum-assets/` 和 `/minio/museum-assets/` 反向代理到 MinIO
|
||||||
|
- 静态资源设置 30 天缓存
|
||||||
|
|
||||||
|
### 4.1 H5 SDK 与后端代理规则
|
||||||
|
|
||||||
|
H5 页面运行在 HTTPS 下,前端运行时代码不能直接请求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://1.92.206.90:3001/app-api/...
|
||||||
|
http://1.92.206.90:9000/museum-assets/...
|
||||||
|
```
|
||||||
|
|
||||||
|
否则浏览器会拦截 mixed content,表现为 SDK 后端接口请求失败或 3D 模型资源 `Failed to fetch`。
|
||||||
|
|
||||||
|
前端 H5 构建环境应使用同源路径:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
VITE_DATA_SOURCE_MODE=sdk
|
||||||
|
VITE_API_BASE_URL=/app-api
|
||||||
|
VITE_SGS_API_BASE_URL=/app-api
|
||||||
|
VITE_SGS_MAP_ID=1
|
||||||
|
VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js
|
||||||
|
VITE_SGS_H5_ENGINE_URL=/h5-sdk
|
||||||
|
VITE_SGS_SDK_ORIGIN=https://guide.whaoyue.com:4433
|
||||||
|
```
|
||||||
|
|
||||||
|
`VITE_SGS_H5_ENGINE_URL=/h5-sdk` 仅在 H5 SDK 上游恢复后可用;2026-07-22 公网响应为 `502`。
|
||||||
|
|
||||||
|
Nginx 容器内的 `443 ssl` server 中必须包含以下代理规则。宿主机 `4433` 通过 Docker 映射进入容器 `443`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /app-api/ {
|
||||||
|
proxy_pass http://172.17.0.1:48080/yudao-server/app-api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /h5-sdk {
|
||||||
|
proxy_pass http://172.17.0.1:3001/h5-sdk;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /minio/museum-assets/ {
|
||||||
|
proxy_pass http://172.17.0.1:9000/museum-assets/;
|
||||||
|
proxy_set_header Host 172.17.0.1:9000;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /museum-assets/ {
|
||||||
|
proxy_pass http://172.17.0.1:9000/museum-assets/;
|
||||||
|
proxy_set_header Host 172.17.0.1:9000;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次修改后检查并重载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx-server nginx -t
|
||||||
|
docker exec nginx-server nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
检查配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx-server nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
重载配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx-server nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
重启容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker restart nginx-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Docker 端口映射
|
||||||
|
|
||||||
|
当前容器没有 compose 管理,端口映射保存在容器启动参数中。检查当前值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect nginx-server --format '{{json .HostConfig.PortBindings}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
期望结果包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
宿主机 8888 -> 容器 80
|
||||||
|
宿主机 4433 -> 容器 443
|
||||||
|
宿主机 7080 -> 容器 7080
|
||||||
|
宿主机 7081 -> 容器 7081
|
||||||
|
```
|
||||||
|
|
||||||
|
需要重建容器时,先保存检查信息并保留旧容器用于回滚:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ts=$(date +%Y%m%d%H%M%S)
|
||||||
|
docker inspect nginx-server \
|
||||||
|
> "/dmdata/nginx/backups/nginx-server-inspect-before-port-change-${ts}.json"
|
||||||
|
docker stop nginx-server
|
||||||
|
docker rename nginx-server "nginx-server-before-port-change-${ts}"
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name nginx-server \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8888:80 \
|
||||||
|
-p 4433:443 \
|
||||||
|
-p 7080:7080 \
|
||||||
|
-p 7081:7081 \
|
||||||
|
-v /dmdata/nginx/conf.d:/etc/nginx/conf.d:ro \
|
||||||
|
-v /dmdata/nginx/ssl:/etc/nginx/ssl:ro \
|
||||||
|
-v /dmdata/nginx/html:/usr/share/nginx/html:ro \
|
||||||
|
-v /dmdata/nginx/logs:/var/log/nginx \
|
||||||
|
arm64v8/nginx:stable-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器防火墙必须持久化放行新端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
firewall-cmd --permanent --add-port=8888/tcp
|
||||||
|
firewall-cmd --permanent --add-port=4433/tcp
|
||||||
|
firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后先执行 `nginx -t` 和第 6 节公网验证。全部通过后再删除已停止的旧容器。
|
||||||
|
|
||||||
|
## 5. SSL 证书
|
||||||
|
|
||||||
|
证书客户端:`acme.sh`
|
||||||
|
|
||||||
|
证书颁发机构:Let’s Encrypt
|
||||||
|
|
||||||
|
证书路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem
|
||||||
|
/dmdata/nginx/ssl/guide.whaoyue.com/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
证书信息:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Subject: CN = guide.whaoyue.com
|
||||||
|
Issuer: Let's Encrypt YR2
|
||||||
|
Not Before: 2026-06-07 15:33:10 GMT
|
||||||
|
Not After: 2026-09-05 15:33:09 GMT
|
||||||
|
```
|
||||||
|
|
||||||
|
查看证书:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -subject -issuer -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
自动续期:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -l | grep acme
|
||||||
|
```
|
||||||
|
|
||||||
|
当前续期任务:
|
||||||
|
|
||||||
|
```text
|
||||||
|
11 7 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
当前证书配置使用 Webroot HTTP-01:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Le_Webroot='/dmdata/nginx/html/guide'
|
||||||
|
```
|
||||||
|
|
||||||
|
Let’s Encrypt HTTP-01 固定验证公网 `80`,不会访问 `8888`。因此改为 `8888/4433` 后,cron 存在不代表证书能够自动续期。必须在证书到期前完成以下任一方案:
|
||||||
|
|
||||||
|
1. 续期窗口临时恢复安全组、防火墙和 Docker 的公网 `80 -> 容器 80` 映射,续期完成后再关闭。
|
||||||
|
2. 将 `acme.sh` 迁移为 DNS-01,并配置可用的 DNS API 凭据。
|
||||||
|
|
||||||
|
当前证书到期时间为 `2026-09-05 15:33:09 GMT`,迁移或续期测试应提前完成。
|
||||||
|
|
||||||
|
`acme.sh` 已配置证书安装后的 reload 命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx-server nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
满足公网 `80` 可验证或已迁移 DNS-01 后,才可执行手动续期测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/root/.acme.sh/acme.sh --cron --home /root/.acme.sh --force
|
||||||
|
```
|
||||||
|
|
||||||
|
测试后必须重新检查证书到期时间,并从公网验证 `4433`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -dates
|
||||||
|
curl -I https://guide.whaoyue.com:4433/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 部署步骤复盘
|
||||||
|
|
||||||
|
1. 确认 DNS:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Resolve-DnsName guide.whaoyue.com -Type A
|
||||||
|
```
|
||||||
|
|
||||||
|
结果应指向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1.92.206.90
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 确认公网端口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-NetConnection guide.whaoyue.com -Port 8888
|
||||||
|
Test-NetConnection guide.whaoyue.com -Port 4433
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 创建静态测试页:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /dmdata/nginx/html/guide/.well-known/acme-challenge
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 写入 Nginx 站点配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/dmdata/nginx/conf.d/guide.whaoyue.com.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 申请证书(以下是 2026-06-08 使用公网 `80` 完成首次签发的历史命令;当前端口映射下不可直接用于 HTTP-01 续期):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt
|
||||||
|
/root/.acme.sh/acme.sh --issue --server letsencrypt -d guide.whaoyue.com -w /dmdata/nginx/html/guide --keylength 2048
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 安装证书:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/root/.acme.sh/acme.sh --install-cert -d guide.whaoyue.com \
|
||||||
|
--key-file /dmdata/nginx/ssl/guide.whaoyue.com/privkey.pem \
|
||||||
|
--fullchain-file /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem \
|
||||||
|
--reloadcmd "docker exec nginx-server nginx -s reload"
|
||||||
|
```
|
||||||
|
|
||||||
|
7. 验证访问:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I http://guide.whaoyue.com:8888/
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/h5-sdk
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
期望结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 301 -> https://guide.whaoyue.com:4433/
|
||||||
|
HTTPS 200 OK
|
||||||
|
/app-api/... 200 application/json
|
||||||
|
/h5-sdk 当前为 502,修复上游或移除废弃规则前不得记为通过
|
||||||
|
/static/sgs-map-sdk/index.global.js 200 application/javascript
|
||||||
|
模型 GLB 200 model/gltf-binary
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 已处理的问题
|
||||||
|
|
||||||
|
本节中的无端口 URL、404 和 Mixed Content 文本是端口调整前的历史故障原文;当前复测命令统一使用 `8888/4433`。
|
||||||
|
|
||||||
|
### 7.1 Docker Nginx 配置校验失败
|
||||||
|
|
||||||
|
容器内 `nginx -t` 曾失败,原因是 Linux Docker 容器无法解析 `host.docker.internal`。
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 将 `/dmdata/nginx/conf.d/https.conf` 中的 `host.docker.internal` 代理目标改为 Docker 默认网关 `172.17.0.1`
|
||||||
|
- 重新执行 `docker exec nginx-server nginx -t`
|
||||||
|
- 配置校验通过后 reload Nginx
|
||||||
|
|
||||||
|
### 7.2 公网端口调整为 8888/4433
|
||||||
|
|
||||||
|
系统级 Nginx 已安装,但站点继续由 `nginx-server` 容器承载。2026-07-22 将宿主机映射从 `80:80`、`443:443` 调整为 `8888:80`、`4433:443`,并保留 `7080/7081` 映射。Nginx 容器内仍监听标准 `80/443`。
|
||||||
|
|
||||||
|
HTTP server 的跳转目标必须显式包含新 HTTPS 端口:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
return 301 https://$host:4433$request_uri;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 H5 SDK 接口请求混合内容
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Request URL http://1.92.206.90:3001/app-api/gis/sdk/maps/1/manifest
|
||||||
|
Referrer Policy strict-origin-when-cross-origin
|
||||||
|
```
|
||||||
|
|
||||||
|
页面实际运行在 `https://guide.whaoyue.com:4433/`,但构建产物中固化了 HTTP 后端地址,浏览器会拦截 mixed content 请求。若 Nginx 未配置 `/app-api/` 代理,改成同源路径后还会被 SPA 回退到 `index.html`。
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 前端构建环境改为同源 `/app-api`、`/h5-sdk`、`/static/sgs-map-sdk/index.global.js`
|
||||||
|
- Nginx 增加 `/app-api/` 和 `/h5-sdk` 代理到宿主机 `3001`
|
||||||
|
- 重新构建并部署 H5 包
|
||||||
|
- 扫描构建产物,确认运行时代码不再包含 `1.92.206.90:3001`
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/h5-sdk
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 SDK 模型资源请求混合内容
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
|
- SDK 接口已经返回 `200 JSON`
|
||||||
|
- 页面仍提示 `3D 模型加载失败` 或 `Failed to fetch`
|
||||||
|
- 后端 bundle 中部分 `modelUrl` 为 `http://1.92.206.90:9000/museum-assets/...`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
HTTPS 页面加载 HTTP MinIO 模型资源会被浏览器拦截;另外后端也可能返回 `/minio/museum-assets/...`,如果 Nginx 未配置该前缀,会落入 SPA 回退并返回 `index.html`。
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- Nginx 同时配置 `/museum-assets/` 和 `/minio/museum-assets/` 到 MinIO
|
||||||
|
- 前端对已知 `http://1.92.206.90:9000/museum-assets/...` 模型地址改写为同源 `/museum-assets/...`
|
||||||
|
- 重新构建并部署 H5 包
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
期望均返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: model/gltf-binary
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 展厅图标与占位图静态资源 404
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/biology.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/evolution.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/dinosaur.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/earth.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/human.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/homeland.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/icons/halls/universe.jpg 404
|
||||||
|
GET https://guide.whaoyue.com/static/exhibit-placeholder.jpg 404
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 源码目录存在 `static/icons/halls/*.jpg`,但 H5 构建后的补充复制脚本只复制了 `nav-assets`、`guide-data`、`sgs-map-sdk`、`three`,没有稳定复制 `static/icons`。
|
||||||
|
- 页面与音频播放器中曾使用 `/static/exhibit-placeholder.jpg`、`/static/hall-placeholder.jpg` 作为兜底图,但静态目录里没有对应文件。
|
||||||
|
- 旧浏览器缓存的 chunk 可能仍请求旧占位图路径,因此即使新代码改用 SVG 兜底,也需要保留兼容文件。
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 更新 `frontend-miniapp/scripts/copy-h5-nav-assets.cjs`,将 `static/icons` 纳入 H5 构建产物复制清单。
|
||||||
|
- 新代码兜底图改为已存在且会随包发布的:
|
||||||
|
- `/static/icons/marker-exhibit.svg`
|
||||||
|
- `/static/icons/marker-hall.svg`
|
||||||
|
- 同时补充兼容文件:
|
||||||
|
- `/static/exhibit-placeholder.jpg`
|
||||||
|
- `/static/hall-placeholder.jpg`
|
||||||
|
- 重新执行 `pnpm type-check`、`pnpm build:h5` 并部署 H5 包。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/biology.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/evolution.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/dinosaur.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/earth.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/human.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/homeland.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/universe.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/exhibit-placeholder.jpg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/hall-placeholder.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
期望返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 SVG 兜底图验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/marker-exhibit.svg
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/static/icons/marker-hall.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
期望返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: image/svg+xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 TTS 语音播放 Mixed Content
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mixed Content: The page at 'https://guide.whaoyue.com/#/pages/exhibit/detail?id=715805000000000009&tab=explain'
|
||||||
|
was loaded over HTTPS, but requested an insecure audio file
|
||||||
|
'http://1.92.206.90:9000/tts-audio/audio/2026/06/30/947228d3e5174f3697f22cceac30075e.mp3'.
|
||||||
|
This request has been blocked; the content must be served over HTTPS.
|
||||||
|
|
||||||
|
详情音频不可播放: 音频加载失败,当前提供图文讲解。
|
||||||
|
H5 音频播放被拦截或失败: NotSupportedError: Failed to load because no supported source was found.
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 页面运行在 HTTPS 下,但后端语音播放接口返回的 `playUrl` 指向 `http://1.92.206.90:9000/tts-audio/...`。
|
||||||
|
- 浏览器不会把 IP 地址 HTTP 音频自动升级为 HTTPS,因此音频请求被 mixed content 策略拦截。
|
||||||
|
- 服务器 Nginx 已配置 `/tts-audio/` 到 MinIO 的 HTTPS 同源代理,因此核心修复点是前端 URL 归一化,而不是新增代理。
|
||||||
|
|
||||||
|
当前 Nginx 代理规则应包含:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /tts-audio/ {
|
||||||
|
proxy_pass http://172.17.0.1:9000/tts-audio/;
|
||||||
|
proxy_set_header Host 172.17.0.1:9000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Range $http_range;
|
||||||
|
proxy_set_header If-Range $http_if_range;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
|
||||||
|
add_header Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" always;
|
||||||
|
add_header Cache-Control "public, max-age=604800" always;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 更新前端统一公共 URL 归一化逻辑 `normalizeSameOriginPublicUrl`:
|
||||||
|
- `http://1.92.206.90:9000/museum-assets/...` -> `/museum-assets/...`
|
||||||
|
- `http://1.92.206.90:9000/tts-audio/...` -> `/tts-audio/...`
|
||||||
|
- `http://47.120.48.148:19000/nhm/audio/...` -> `/nhm/audio/...`
|
||||||
|
- 确认 `AudioPlayInfoRepository`、后端讲解数据 adapter、静态导览 adapter 均通过该统一方法处理音频 URL。
|
||||||
|
- 重新构建并部署 H5 包。
|
||||||
|
- 扫描线上运行时代码,确认不再包含 `http://1.92.206.90:9000/tts-audio` 或其他 MinIO HTTP 直连。
|
||||||
|
|
||||||
|
验证时不要复用已经删除的历史 MinIO 对象。先从当前播放信息接口取得 `playUrl`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe "https://guide.whaoyue.com:4433/app-api/gis/guide/audio/play-info?targetType=STOP&targetId=865546638960193536&lang=zh-CN"
|
||||||
|
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/tts-audio/audio/2026/07/17/63058096489b4f4aa516fb07fe4a1144.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
期望返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: audio/mpeg
|
||||||
|
Accept-Ranges: bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
线上运行时代码扫描:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -R 'http://1\.92\.206\.90:9000/tts-audio\|http://1\.92\.206\.90:9000' \
|
||||||
|
-n /dmdata/nginx/html/guide/assets /dmdata/nginx/html/guide/index.html || true
|
||||||
|
```
|
||||||
|
|
||||||
|
期望无输出。
|
||||||
|
|
||||||
|
## 8. 回滚方式
|
||||||
|
|
||||||
|
配置备份目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/dmdata/nginx/backups/20260608002938
|
||||||
|
/dmdata/nginx/backups/20260608003215
|
||||||
|
/dmdata/nginx/backups/guide.whaoyue.com.conf-before-port-change-20260722175845
|
||||||
|
/dmdata/nginx/backups/nginx-server-inspect-before-port-change-20260722175845.json
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚配置示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -a /dmdata/nginx/backups/20260608003215/conf.d/* /dmdata/nginx/conf.d/
|
||||||
|
docker exec nginx-server nginx -t
|
||||||
|
docker exec nginx-server nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
如需回滚 2026-07-22 的端口调整,应根据检查快照重建容器为 `80:80`、`443:443`、`7080:7080`、`7081:7081`,同时恢复站点配置中的标准 HTTPS 跳转。执行前必须再次备份当前容器检查信息和站点配置。
|
||||||
|
|
||||||
|
如需移除本域名站点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f /dmdata/nginx/conf.d/guide.whaoyue.com.conf
|
||||||
|
docker exec nginx-server nginx -t
|
||||||
|
docker exec nginx-server nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 运行维护检查清单
|
||||||
|
|
||||||
|
日常检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter name=nginx-server
|
||||||
|
docker exec nginx-server nginx -t
|
||||||
|
curl -I http://guide.whaoyue.com:8888/
|
||||||
|
curl -I https://guide.whaoyue.com:4433/
|
||||||
|
curl -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
|
||||||
|
curl -I https://guide.whaoyue.com:4433/h5-sdk
|
||||||
|
curl -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
|
||||||
|
curl -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
|
||||||
|
curl -I https://guide.whaoyue.com:4433/minio/museum-assets/tts-audio/audio/2026/07/17/63058096489b4f4aa516fb07fe4a1144.mp3
|
||||||
|
curl -I https://guide.whaoyue.com:4433/static/icons/halls/biology.jpg
|
||||||
|
curl -I https://guide.whaoyue.com:4433/static/exhibit-placeholder.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /dmdata/nginx/logs/guide-http-access.log
|
||||||
|
tail -f /dmdata/nginx/logs/guide-https-access.log
|
||||||
|
tail -f /dmdata/nginx/logs/guide-https-error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
证书检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/root/.acme.sh/acme.sh --list
|
||||||
|
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -subject -issuer -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 验收记录
|
||||||
|
|
||||||
|
2026-06-08 验收结果:
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| DNS A 记录 | 通过 | `guide.whaoyue.com -> 1.92.206.90` |
|
||||||
|
| 80 端口公网连通 | 通过 | `Test-NetConnection guide.whaoyue.com -Port 80 = True` |
|
||||||
|
| 443 端口公网连通 | 通过 | `Test-NetConnection guide.whaoyue.com -Port 443 = True` |
|
||||||
|
| HTTP 跳转 | 通过 | `HTTP/1.1 301 Moved Permanently` |
|
||||||
|
| HTTPS 静态页 | 通过 | `HTTP/1.1 200 OK` |
|
||||||
|
| Nginx 配置 | 通过 | `nginx: configuration file /etc/nginx/nginx.conf test is successful` |
|
||||||
|
| SSL 证书 | 通过 | `CN = guide.whaoyue.com`, Let’s Encrypt |
|
||||||
|
| 自动续期 | 通过 | `acme.sh` cron 已存在 |
|
||||||
|
|
||||||
|
2026-06-26 SDK 后端与模型资源修复验收:
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SDK manifest 同源接口 | 通过 | `/app-api/gis/sdk/maps/1/manifest -> 200 application/json` |
|
||||||
|
| H5 SDK 基座 | 通过 | `/h5-sdk -> 200 text/html` |
|
||||||
|
| SDK 脚本 | 通过 | `/static/sgs-map-sdk/index.global.js -> 200 application/javascript` |
|
||||||
|
| MinIO `/museum-assets/` 模型 | 通过 | `draco_EXTERIOR.glb_1781708354536.glb -> 200 model/gltf-binary` |
|
||||||
|
| MinIO `/minio/museum-assets/` 模型 | 通过 | `L1_draco.glb -> 200 model/gltf-binary` |
|
||||||
|
| 混合内容引用 | 通过 | H5 运行时代码无 `1.92.206.90:3001`、`1.92.206.90:9000` |
|
||||||
|
|
||||||
|
2026-06-30 静态图标、占位图与 TTS 音频修复验收:
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 展厅图标资源 | 通过 | `/static/icons/halls/biology.jpg` 等展厅图标 -> `200 image/jpeg` |
|
||||||
|
| 展品占位图兼容资源 | 通过 | `/static/exhibit-placeholder.jpg -> 200 image/jpeg` |
|
||||||
|
| 展厅占位图兼容资源 | 通过 | `/static/hall-placeholder.jpg -> 200 image/jpeg` |
|
||||||
|
| SVG 兜底图资源 | 通过 | `/static/icons/marker-exhibit.svg -> 200 image/svg+xml` |
|
||||||
|
| TTS 音频 HTTPS 代理 | 通过 | `/tts-audio/audio/2026/06/30/947228d3e5174f3697f22cceac30075e.mp3 -> 200 audio/mpeg` |
|
||||||
|
| TTS CORS/Range 响应 | 通过 | 响应包含 `Access-Control-Allow-Origin: *`、`Accept-Ranges: bytes` |
|
||||||
|
| 混合内容引用 | 通过 | H5 运行时代码无 `http://1.92.206.90:9000/tts-audio`、无 `http://1.92.206.90:9000` |
|
||||||
|
|
||||||
|
2026-07-22 公网端口调整验收:
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Docker HTTP 映射 | 通过 | `0.0.0.0:8888 -> 80/tcp` |
|
||||||
|
| Docker HTTPS 映射 | 通过 | `0.0.0.0:4433 -> 443/tcp` |
|
||||||
|
| 既有端口保留 | 通过 | `7080/7081 -> 7080/7081` |
|
||||||
|
| 8888 端口公网连通 | 通过 | `http://guide.whaoyue.com:8888/ -> 301` |
|
||||||
|
| HTTP 跳转目标 | 通过 | `Location: https://guide.whaoyue.com:4433/` |
|
||||||
|
| 4433 首页 | 通过 | `https://guide.whaoyue.com:4433/ -> 200` |
|
||||||
|
| 4433 API 代理 | 通过 | `/app-api/gis/hall/list -> 200 application/json` |
|
||||||
|
| 域名校验文件 | 通过 | `/gpL0svkeao.txt -> 200 text/plain` |
|
||||||
|
| 当前语音对象 | 通过 | 播放信息返回的 `/minio/museum-assets/tts-audio/...mp3 -> 200 audio/mpeg` |
|
||||||
|
| H5 SDK 基座 | 阻塞 | `/h5-sdk -> 502`,不得沿用 2026-06-26 的 200 结论 |
|
||||||
|
| Nginx 配置 | 通过 | `nginx: configuration file /etc/nginx/nginx.conf test is successful` |
|
||||||
|
| 证书续期 | 待处理 | 当前 Webroot HTTP-01 不能通过公网 `8888` 完成验证,需恢复 `80` 或迁移 DNS-01 |
|
||||||
61
scripts/finalize-h5-build-policy.cjs
Normal file
61
scripts/finalize-h5-build-policy.cjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const { loadEnv } = require('vite')
|
||||||
|
|
||||||
|
const tencentMapKeyPlaceholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
|
||||||
|
const tencentMapKeyPattern = /^[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4,6}$/i
|
||||||
|
|
||||||
|
const isUsableTencentMapKey = (value) => (
|
||||||
|
tencentMapKeyPattern.test(value)
|
||||||
|
&& value !== tencentMapKeyPlaceholder
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveH5BuildPolicy = ({
|
||||||
|
args = [],
|
||||||
|
projectRoot,
|
||||||
|
environment = process.env,
|
||||||
|
loadProductionEnv = (root) => loadEnv('production', root, '')
|
||||||
|
}) => {
|
||||||
|
const flags = new Set(args)
|
||||||
|
const requireTencentMapKey = flags.has('--require-tencent-map-key')
|
||||||
|
const allowPlaceholder = flags.has('--allow-placeholder')
|
||||||
|
|
||||||
|
if (requireTencentMapKey && allowPlaceholder) {
|
||||||
|
throw new Error('H5 build cannot require a Tencent map key and allow its placeholder at the same time.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requireTencentMapKey) {
|
||||||
|
return {
|
||||||
|
allowPlaceholder,
|
||||||
|
requireTencentMapKey,
|
||||||
|
tencentMapKey: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionEnv = loadProductionEnv(projectRoot)
|
||||||
|
const tencentMapKey = (
|
||||||
|
environment.VITE_TENCENT_MAP_KEY
|
||||||
|
|| productionEnv.VITE_TENCENT_MAP_KEY
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!isUsableTencentMapKey(tencentMapKey)) {
|
||||||
|
throw new Error([
|
||||||
|
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
|
||||||
|
'Inject it through CI or an ignored .env.production.local file.'
|
||||||
|
].join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowPlaceholder,
|
||||||
|
requireTencentMapKey,
|
||||||
|
tencentMapKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTextBuildFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml|map)$/i.test(filePath)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isTextBuildFile,
|
||||||
|
isUsableTencentMapKey,
|
||||||
|
resolveH5BuildPolicy,
|
||||||
|
tencentMapKeyPlaceholder
|
||||||
|
}
|
||||||
@@ -1,29 +1,36 @@
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const { spawnSync } = require('node:child_process')
|
const { spawnSync } = require('node:child_process')
|
||||||
|
const {
|
||||||
require('./copy-h5-nav-assets.cjs')
|
isTextBuildFile,
|
||||||
|
resolveH5BuildPolicy,
|
||||||
|
tencentMapKeyPlaceholder
|
||||||
|
} = require('./finalize-h5-build-policy.cjs')
|
||||||
|
|
||||||
const projectRoot = path.resolve(__dirname, '..')
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
const h5Root = path.join(projectRoot, 'dist', 'build', 'h5')
|
const h5Root = path.join(projectRoot, 'dist', 'build', 'h5')
|
||||||
const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
|
const placeholder = tencentMapKeyPlaceholder
|
||||||
const badPatterns = [
|
const badPatterns = [
|
||||||
'http://1.92.206.90:9000',
|
'http://1.92.206.90:9000',
|
||||||
'1.92.206.90',
|
'1.92.206.90',
|
||||||
'guide.whaoyue.com',
|
'guide.whaoyue.com',
|
||||||
placeholder
|
placeholder
|
||||||
]
|
]
|
||||||
const args = new Set(process.argv.slice(2))
|
const {
|
||||||
const requireTencentMapKey = args.has('--require-tencent-map-key')
|
allowPlaceholder,
|
||||||
const allowPlaceholder = args.has('--allow-placeholder')
|
requireTencentMapKey,
|
||||||
const tencentMapKey = (process.env.VITE_TENCENT_MAP_KEY || '').trim()
|
tencentMapKey
|
||||||
|
} = resolveH5BuildPolicy({
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
projectRoot
|
||||||
|
})
|
||||||
|
|
||||||
|
require('./copy-h5-nav-assets.cjs')
|
||||||
|
|
||||||
if (!fs.existsSync(h5Root)) {
|
if (!fs.existsSync(h5Root)) {
|
||||||
throw new Error(`H5 build output not found: ${h5Root}`)
|
throw new Error(`H5 build output not found: ${h5Root}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTextFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml)$/i.test(filePath)
|
|
||||||
|
|
||||||
const walkFiles = (dir, files = []) => {
|
const walkFiles = (dir, files = []) => {
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
const filePath = path.join(dir, entry.name)
|
const filePath = path.join(dir, entry.name)
|
||||||
@@ -42,16 +49,21 @@ const walkFiles = (dir, files = []) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allFiles = walkFiles(h5Root)
|
const allFiles = walkFiles(h5Root)
|
||||||
const textFiles = allFiles.filter(isTextFile)
|
const textFiles = allFiles.filter(isTextBuildFile)
|
||||||
const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder))
|
const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder))
|
||||||
const hasUsableTencentMapKey = tencentMapKey.length > 0 && tencentMapKey !== placeholder
|
const hasUsableTencentMapKey = tencentMapKey.length > 0
|
||||||
|
const missingTencentMapKeyMessage = [
|
||||||
|
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
|
||||||
|
'Inject it through CI or an ignored .env.production.local file.'
|
||||||
|
].join(' ')
|
||||||
|
|
||||||
|
if (requireTencentMapKey && !hasUsableTencentMapKey) {
|
||||||
|
throw new Error(missingTencentMapKeyMessage)
|
||||||
|
}
|
||||||
|
|
||||||
if (placeholderFiles.length > 0) {
|
if (placeholderFiles.length > 0) {
|
||||||
if (!hasUsableTencentMapKey) {
|
if (!hasUsableTencentMapKey) {
|
||||||
const message = [
|
const message = `H5 build output still contains Tencent map key placeholder. ${missingTencentMapKeyMessage}`
|
||||||
'H5 build output still contains Tencent map key placeholder.',
|
|
||||||
'Please set VITE_TENCENT_MAP_KEY before production build.'
|
|
||||||
].join(' ')
|
|
||||||
|
|
||||||
if (requireTencentMapKey && !allowPlaceholder) {
|
if (requireTencentMapKey && !allowPlaceholder) {
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
@@ -108,8 +120,8 @@ for (const file of textFiles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (badMatches.length > 0) {
|
if (badMatches.length > 0) {
|
||||||
if (allowPlaceholder && badMatches.every((item) => item.pattern === placeholder)) {
|
if (allowPlaceholder) {
|
||||||
console.warn('[finalize-h5-build] Tencent map key placeholder is allowed for this build.')
|
console.warn('[finalize-h5-build] Test H5 build allows non-production source hosts and the Tencent map key placeholder.')
|
||||||
} else {
|
} else {
|
||||||
console.error(JSON.stringify(badMatches.slice(0, 20), null, 2))
|
console.error(JSON.stringify(badMatches.slice(0, 20), null, 2))
|
||||||
throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.')
|
throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.')
|
||||||
|
|||||||
@@ -583,17 +583,21 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFloorChange = (floor: { id: string; label: string }) => {
|
const requestFloorSwitch = (
|
||||||
|
floor: { id: string; label: string },
|
||||||
|
options: { force?: boolean } = {}
|
||||||
|
) => {
|
||||||
const floorId = floor.id
|
const floorId = floor.id
|
||||||
if (!floorId || loadingFloorId.value) return
|
if (!floorId) return Promise.resolve()
|
||||||
|
if (loadingFloorId.value === floorId) return Promise.resolve()
|
||||||
if (
|
if (
|
||||||
floorId === renderedFloorId.value
|
!options.force && floorId === renderedFloorId.value
|
||||||
&& activeFloorId.value === floorId
|
&& activeFloorId.value === floorId
|
||||||
&& props.indoorView === 'floor'
|
&& props.indoorView === 'floor'
|
||||||
&& props.layerMode !== 'multi'
|
&& props.layerMode !== 'multi'
|
||||||
) {
|
) {
|
||||||
emit('floorChange', floorId)
|
emit('floorChange', floorId)
|
||||||
return
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
requestedFloorId.value = floorId
|
requestedFloorId.value = floorId
|
||||||
@@ -607,7 +611,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
|
|||||||
floorId,
|
floorId,
|
||||||
floorLabel: floor.label
|
floorLabel: floor.label
|
||||||
})
|
})
|
||||||
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
return Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
||||||
})
|
})
|
||||||
@@ -623,6 +627,10 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFloorChange = (floor: { id: string; label: string }) => {
|
||||||
|
void requestFloorSwitch(floor)
|
||||||
|
}
|
||||||
|
|
||||||
const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||||||
// 手动切换展示层数时使用统一的短保护期。
|
// 手动切换展示层数时使用统一的短保护期。
|
||||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||||
@@ -789,7 +797,11 @@ defineExpose({
|
|||||||
indoorRendererRef.value?.clearRoute?.()
|
indoorRendererRef.value?.clearRoute?.()
|
||||||
},
|
},
|
||||||
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
|
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
|
||||||
switchFloor: (floorId: string) => indoorRendererRef.value?.switchFloor?.(floorId),
|
switchFloor: (floorId: string) => {
|
||||||
|
const floor = findFloorItemById(floorId)
|
||||||
|
if (!floor) return Promise.resolve()
|
||||||
|
return requestFloorSwitch(floor, { force: true })
|
||||||
|
},
|
||||||
showOverview: handleShowOverview,
|
showOverview: handleShowOverview,
|
||||||
resetToViewBaseline: (options: {
|
resetToViewBaseline: (options: {
|
||||||
view: 'overview' | 'floor'
|
view: 'overview' | 'floor'
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ import type {
|
|||||||
PoiCategoryResultState,
|
PoiCategoryResultState,
|
||||||
PoiSearchContext
|
PoiSearchContext
|
||||||
} from '@/domain/poiSearch'
|
} from '@/domain/poiSearch'
|
||||||
|
import { nextHomeSearchResultVersion } from './homeSearchResultVersion'
|
||||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -397,7 +398,8 @@ const emitResultsState = () => {
|
|||||||
emit('results-change', {
|
emit('results-change', {
|
||||||
...context,
|
...context,
|
||||||
visiblePoiIds: active ? context.visiblePoiIds : [],
|
visiblePoiIds: active ? context.visiblePoiIds : [],
|
||||||
active
|
active,
|
||||||
|
requestId: props.variant === 'home' ? nextHomeSearchResultVersion() : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +413,8 @@ const emitPendingHomeSearchResults = (floor?: Pick<MuseumFloor, 'id' | 'label'>)
|
|||||||
floorLabel: floor?.label || context.floorLabel,
|
floorLabel: floor?.label || context.floorLabel,
|
||||||
visiblePoiIds: [],
|
visiblePoiIds: [],
|
||||||
active: true,
|
active: true,
|
||||||
pending: true
|
pending: true,
|
||||||
|
requestId: nextHomeSearchResultVersion()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
src/components/search/homeSearchResultVersion.ts
Normal file
7
src/components/search/homeSearchResultVersion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This version survives a PoiSearchPanel remount, unlike its local request sequence.
|
||||||
|
let latestHomeSearchResultVersion = 0
|
||||||
|
|
||||||
|
export const nextHomeSearchResultVersion = () => {
|
||||||
|
latestHomeSearchResultVersion += 1
|
||||||
|
return latestHomeSearchResultVersion
|
||||||
|
}
|
||||||
@@ -259,7 +259,7 @@ const resume = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchLanguage = async (lang: AudioLanguage) => {
|
const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null) => {
|
||||||
if (pendingLanguage.value) {
|
if (pendingLanguage.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -291,6 +291,35 @@ const switchLanguage = async (lang: AudioLanguage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (localAudio?.audioUrl) {
|
||||||
|
currentSource.value = nextSource
|
||||||
|
return await play(localAudio, {
|
||||||
|
source: nextSource,
|
||||||
|
retryOnError: retryOnError || undefined,
|
||||||
|
displayMode: preservedMode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localAudio) {
|
||||||
|
stopAudioElement()
|
||||||
|
currentSource.value = nextSource
|
||||||
|
currentAudio.value = {
|
||||||
|
...localAudio,
|
||||||
|
audioUrl: '',
|
||||||
|
duration: 0,
|
||||||
|
language: lang
|
||||||
|
}
|
||||||
|
visible.value = true
|
||||||
|
displayMode.value = preservedMode
|
||||||
|
playing.value = false
|
||||||
|
loading.value = false
|
||||||
|
currentTime.value = 0
|
||||||
|
duration.value = 0
|
||||||
|
error.value = '当前语言暂无语音讲解'
|
||||||
|
showToast(error.value)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const playInfo = await audioPlayInfoRepository.getPlayInfo({
|
const playInfo = await audioPlayInfoRepository.getPlayInfo({
|
||||||
targetType: source.targetType,
|
targetType: source.targetType,
|
||||||
targetId: source.targetId,
|
targetId: source.targetId,
|
||||||
|
|||||||
@@ -42,11 +42,31 @@ export interface BackendGuideStopInfo {
|
|||||||
hasText?: boolean
|
hasText?: boolean
|
||||||
supportedLanguages?: string[] | null
|
supportedLanguages?: string[] | null
|
||||||
audioStatus?: string | null
|
audioStatus?: string | null
|
||||||
|
languageVariants?: BackendGuideStopLanguageVariant[] | null
|
||||||
reason?: string | null
|
reason?: string | null
|
||||||
linkedExhibitCount?: number | string | null
|
linkedExhibitCount?: number | string | null
|
||||||
isSharedStop?: boolean | null
|
isSharedStop?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackendGuideStopLanguageVariant {
|
||||||
|
lang?: string | null
|
||||||
|
enabled?: boolean | null
|
||||||
|
playable?: boolean | null
|
||||||
|
audioStatus?: string | null
|
||||||
|
playUrl?: string | null
|
||||||
|
duration?: number | string | null
|
||||||
|
format?: string | null
|
||||||
|
audioId?: string | number | null
|
||||||
|
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
||||||
|
hasText?: boolean | null
|
||||||
|
textAvailable?: boolean | null
|
||||||
|
text?: string | null
|
||||||
|
textLength?: number | string | null
|
||||||
|
textHash?: string | null
|
||||||
|
fallback?: boolean | null
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendAudioPlayInfo {
|
export interface BackendAudioPlayInfo {
|
||||||
playable?: boolean
|
playable?: boolean
|
||||||
targetType?: string | null
|
targetType?: string | null
|
||||||
@@ -90,6 +110,25 @@ export interface GuideStopLinkedExhibit {
|
|||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuideStopLanguageVariant {
|
||||||
|
lang: GuideAudioLanguage
|
||||||
|
enabled: boolean
|
||||||
|
playable: boolean
|
||||||
|
audioStatus: 'READY' | 'MISSING' | string
|
||||||
|
playUrl?: string
|
||||||
|
duration?: number
|
||||||
|
format?: string
|
||||||
|
audioId?: string
|
||||||
|
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||||
|
hasText: boolean
|
||||||
|
textAvailable: boolean
|
||||||
|
text?: string
|
||||||
|
textLength?: number
|
||||||
|
textHash?: string
|
||||||
|
fallback: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GuideStopInfo {
|
export interface GuideStopInfo {
|
||||||
available: boolean
|
available: boolean
|
||||||
targetType: AudioPlayTargetType
|
targetType: AudioPlayTargetType
|
||||||
@@ -112,6 +151,7 @@ export interface GuideStopInfo {
|
|||||||
hasAudio: boolean
|
hasAudio: boolean
|
||||||
hasText: boolean
|
hasText: boolean
|
||||||
supportedLanguages: GuideAudioLanguage[]
|
supportedLanguages: GuideAudioLanguage[]
|
||||||
|
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
|
||||||
audioStatus: 'READY' | 'MISSING' | string
|
audioStatus: 'READY' | 'MISSING' | string
|
||||||
reason?: string
|
reason?: string
|
||||||
linkedExhibitCount?: number
|
linkedExhibitCount?: number
|
||||||
@@ -177,16 +217,57 @@ export const normalizeGuideAudioLanguage = (
|
|||||||
return 'zh-CN'
|
return 'zh-CN'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSupportedGuideAudioLanguage = (value: string | null | undefined) => (
|
||||||
|
['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(value?.trim().toLowerCase() || '')
|
||||||
|
)
|
||||||
|
|
||||||
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
|
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
|
||||||
Array.from(new Set((languages || [])
|
Array.from(new Set((languages || [])
|
||||||
.map((language) => {
|
.filter(isSupportedGuideAudioLanguage)
|
||||||
const normalized = language?.trim().toLowerCase()
|
.map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[]
|
||||||
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) {
|
)
|
||||||
return null
|
|
||||||
|
const normalizeLanguageVariants = (
|
||||||
|
variants: BackendGuideStopLanguageVariant[] | null | undefined
|
||||||
|
): Record<GuideAudioLanguage, GuideStopLanguageVariant> => {
|
||||||
|
const normalizedVariants = {} as Record<GuideAudioLanguage, GuideStopLanguageVariant>
|
||||||
|
|
||||||
|
;(variants || []).forEach((variant) => {
|
||||||
|
if (!isSupportedGuideAudioLanguage(variant.lang)) return
|
||||||
|
|
||||||
|
const lang = normalizeGuideAudioLanguage(variant.lang)
|
||||||
|
const playUrl = normalizeSameOriginPublicUrl(variant.playUrl) || undefined
|
||||||
|
const playable = variant.playable === true && Boolean(playUrl)
|
||||||
|
const text = variant.text?.trim() || undefined
|
||||||
|
const textAvailable = variant.textAvailable === true || Boolean(text)
|
||||||
|
|
||||||
|
normalizedVariants[lang] = {
|
||||||
|
lang,
|
||||||
|
enabled: variant.enabled === true,
|
||||||
|
playable,
|
||||||
|
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
|
||||||
|
playUrl,
|
||||||
|
duration: normalizeNumber(variant.duration),
|
||||||
|
format: variant.format?.trim() || undefined,
|
||||||
|
audioId: stringifyId(variant.audioId) || undefined,
|
||||||
|
narrationTier: normalizeNarrationTier(variant.narrationTier),
|
||||||
|
hasText: variant.hasText === true || textAvailable,
|
||||||
|
textAvailable,
|
||||||
|
text,
|
||||||
|
textLength: normalizeNumber(variant.textLength),
|
||||||
|
textHash: variant.textHash?.trim() || undefined,
|
||||||
|
fallback: variant.fallback === true,
|
||||||
|
reason: variant.reason?.trim() || undefined
|
||||||
}
|
}
|
||||||
return normalizeGuideAudioLanguage(language)
|
|
||||||
})
|
})
|
||||||
.filter(Boolean))) as GuideAudioLanguage[]
|
|
||||||
|
return normalizedVariants
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
|
||||||
|
(Object.values(variants) as GuideStopLanguageVariant[])
|
||||||
|
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
|
||||||
|
.map((variant) => variant.lang)
|
||||||
)
|
)
|
||||||
|
|
||||||
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
||||||
@@ -255,6 +336,8 @@ export const toGuideStopInfo = (
|
|||||||
const coverImageUrl = canUseStopImages
|
const coverImageUrl = canUseStopImages
|
||||||
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
||||||
: undefined
|
: undefined
|
||||||
|
const languageVariants = normalizeLanguageVariants(source.languageVariants)
|
||||||
|
const supportedLanguages = supportedLanguagesFromVariants(languageVariants)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: source.available === true,
|
available: source.available === true,
|
||||||
@@ -277,7 +360,10 @@ export const toGuideStopInfo = (
|
|||||||
playTargetId,
|
playTargetId,
|
||||||
hasAudio: source.hasAudio === true,
|
hasAudio: source.hasAudio === true,
|
||||||
hasText: source.hasText === true,
|
hasText: source.hasText === true,
|
||||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
supportedLanguages: supportedLanguages.length
|
||||||
|
? supportedLanguages
|
||||||
|
: normalizeSupportedLanguages(source.supportedLanguages),
|
||||||
|
languageVariants,
|
||||||
audioStatus: source.audioStatus || 'MISSING',
|
audioStatus: source.audioStatus || 'MISSING',
|
||||||
reason: source.reason || undefined,
|
reason: source.reason || undefined,
|
||||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
isIndoorNavigableFloor
|
isIndoorNavigableFloor
|
||||||
} from '@/domain/guideFloor'
|
} from '@/domain/guideFloor'
|
||||||
import {
|
import {
|
||||||
|
isVisitorRestrictedPlaceName,
|
||||||
normalizePoiSemanticValue
|
normalizePoiSemanticValue
|
||||||
} from '@/domain/poiCategories'
|
} from '@/domain/poiCategories'
|
||||||
|
|
||||||
@@ -193,6 +194,16 @@ const isPoiAccessible = (poi: StaticNavPoiPayload) => (
|
|||||||
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
|
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resolveStaticPoiVisitorVisible = (
|
||||||
|
poi: StaticNavPoiPayload,
|
||||||
|
semanticType: string
|
||||||
|
) => (
|
||||||
|
typeof poi.visitorVisible === 'boolean'
|
||||||
|
? poi.visitorVisible
|
||||||
|
: semanticType !== 'service_space'
|
||||||
|
&& ![poi.name, poi.sourceObjectName].some(isVisitorRestrictedPlaceName)
|
||||||
|
)
|
||||||
|
|
||||||
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
|
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
|
||||||
const categoryFallbackIconType = poi.categories?.[0]?.iconType
|
const categoryFallbackIconType = poi.categories?.[0]?.iconType
|
||||||
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
|
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
|
||||||
@@ -214,6 +225,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
|
|||||||
sourceObjectName: poi.sourceObjectName,
|
sourceObjectName: poi.sourceObjectName,
|
||||||
sourceConfidence: poi.sourceConfidence,
|
sourceConfidence: poi.sourceConfidence,
|
||||||
navigationReadiness: poi.navigationReadiness,
|
navigationReadiness: poi.navigationReadiness,
|
||||||
|
visitorVisible: resolveStaticPoiVisitorVisible(poi, iconType),
|
||||||
accessible: isPoiAccessible(poi),
|
accessible: isPoiAccessible(poi),
|
||||||
kind,
|
kind,
|
||||||
hallName: kind === 'hall' ? poi.name : undefined
|
hallName: kind === 'hall' ? poi.name : undefined
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
isIndoorNavigableFloor
|
isIndoorNavigableFloor
|
||||||
} from '@/domain/guideFloor'
|
} from '@/domain/guideFloor'
|
||||||
import {
|
import {
|
||||||
|
isVisitorRestrictedPlaceName,
|
||||||
isPoiSearchCategorySupported,
|
isPoiSearchCategorySupported,
|
||||||
normalizePoiSemanticValue
|
normalizePoiSemanticValue
|
||||||
} from '@/domain/poiCategories'
|
} from '@/domain/poiCategories'
|
||||||
@@ -146,6 +147,7 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
|
|||||||
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
||||||
exhibition_hall: hallCategory,
|
exhibition_hall: hallCategory,
|
||||||
theater: spaceCategoryBySgsType.theater,
|
theater: spaceCategoryBySgsType.theater,
|
||||||
|
service_space: spaceCategoryBySgsType.service_space,
|
||||||
commercial: spaceCategoryBySgsType.commercial,
|
commercial: spaceCategoryBySgsType.commercial,
|
||||||
restaurant: spaceCategoryBySgsType.restaurant,
|
restaurant: spaceCategoryBySgsType.restaurant,
|
||||||
cafe: spaceCategoryBySgsType.cafe,
|
cafe: spaceCategoryBySgsType.cafe,
|
||||||
@@ -414,6 +416,25 @@ const categoryForBusinessType = (businessType?: string | null): MuseumCategory =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveSgsPoiVisitorVisible = (
|
||||||
|
source: {
|
||||||
|
visitorVisible?: boolean | null
|
||||||
|
name?: string | null
|
||||||
|
sourceNodeName?: string | null
|
||||||
|
anchorNodeName?: string | null
|
||||||
|
},
|
||||||
|
category: MuseumCategory
|
||||||
|
) => (
|
||||||
|
typeof source.visitorVisible === 'boolean'
|
||||||
|
? source.visitorVisible
|
||||||
|
: category.id !== 'space_service'
|
||||||
|
&& ![
|
||||||
|
source.name,
|
||||||
|
source.sourceNodeName,
|
||||||
|
source.anchorNodeName
|
||||||
|
].some(isVisitorRestrictedPlaceName)
|
||||||
|
)
|
||||||
|
|
||||||
interface SgsHallPoiBuildOptions {
|
interface SgsHallPoiBuildOptions {
|
||||||
fallbackY?: number
|
fallbackY?: number
|
||||||
}
|
}
|
||||||
@@ -661,6 +682,7 @@ export const toMuseumPoiFromSgs = (
|
|||||||
sourceObjectName: poi.anchorNodeName || undefined,
|
sourceObjectName: poi.anchorNodeName || undefined,
|
||||||
sourceConfidence: 'backend-sgs-sdk',
|
sourceConfidence: 'backend-sgs-sdk',
|
||||||
navigationReadiness: '位置预览',
|
navigationReadiness: '位置预览',
|
||||||
|
visitorVisible: resolveSgsPoiVisitorVisible(poi, category),
|
||||||
accessible: category.accessible === true,
|
accessible: category.accessible === true,
|
||||||
kind,
|
kind,
|
||||||
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
|
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
|
||||||
@@ -825,6 +847,7 @@ export const toMuseumSpacePointFromSgs = (
|
|||||||
sourceObjectName: space.sourceNodeName || undefined,
|
sourceObjectName: space.sourceNodeName || undefined,
|
||||||
sourceConfidence: spacePosition.sourceConfidence,
|
sourceConfidence: spacePosition.sourceConfidence,
|
||||||
navigationReadiness: '位置预览',
|
navigationReadiness: '位置预览',
|
||||||
|
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
|
||||||
accessible: false,
|
accessible: false,
|
||||||
kind: 'space',
|
kind: 'space',
|
||||||
hallId: category.id === hallCategory.id ? spaceId : undefined,
|
hallId: category.id === hallCategory.id ? spaceId : undefined,
|
||||||
@@ -896,6 +919,7 @@ const createSpaceFallbackHallPoi = (
|
|||||||
sourceObjectName: space.sourceNodeName || undefined,
|
sourceObjectName: space.sourceNodeName || undefined,
|
||||||
sourceConfidence: spacePosition.sourceConfidence,
|
sourceConfidence: spacePosition.sourceConfidence,
|
||||||
navigationReadiness: '位置预览',
|
navigationReadiness: '位置预览',
|
||||||
|
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
|
||||||
accessible: false,
|
accessible: false,
|
||||||
kind: 'hall',
|
kind: 'hall',
|
||||||
hallId,
|
hallId,
|
||||||
@@ -983,6 +1007,7 @@ export const toMuseumHallPoisFromSgs = (
|
|||||||
? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
|
? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
|
||||||
: sgsHallEntranceConfidence,
|
: sgsHallEntranceConfidence,
|
||||||
navigationReadiness: '位置预览',
|
navigationReadiness: '位置预览',
|
||||||
|
visitorVisible: resolveSgsPoiVisitorVisible(matchedSpace, category),
|
||||||
accessible: false,
|
accessible: false,
|
||||||
kind: 'hall',
|
kind: 'hall',
|
||||||
hallId,
|
hallId,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface SgsPoiPayload {
|
|||||||
y?: number | null
|
y?: number | null
|
||||||
z?: number | null
|
z?: number | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
|
visitorVisible?: boolean | null
|
||||||
anchorNodeName?: string | null
|
anchorNodeName?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
iconUrl?: string | null
|
iconUrl?: string | null
|
||||||
@@ -87,6 +88,7 @@ export interface SgsSpacePayload {
|
|||||||
center?: SgsPositionPayload | null
|
center?: SgsPositionPayload | null
|
||||||
sourceNodeName?: string | null
|
sourceNodeName?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
|
visitorVisible?: boolean | null
|
||||||
colorHex?: string | null
|
colorHex?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface StaticNavPoiPayload {
|
|||||||
sourceObjectName?: string
|
sourceObjectName?: string
|
||||||
navigationReadiness?: string
|
navigationReadiness?: string
|
||||||
sourceConfidence?: string
|
sourceConfidence?: string
|
||||||
|
visitorVisible?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StaticNavManifestFloorModelPayload {
|
export interface StaticNavManifestFloorModelPayload {
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export interface MuseumPoi {
|
|||||||
sourceObjectName?: string
|
sourceObjectName?: string
|
||||||
sourceConfidence?: string
|
sourceConfidence?: string
|
||||||
navigationReadiness?: string
|
navigationReadiness?: string
|
||||||
|
/** Explicit visitor-search eligibility supplied by the source or adapter policy. */
|
||||||
|
visitorVisible?: boolean
|
||||||
accessible: boolean
|
accessible: boolean
|
||||||
kind?: MuseumPoiKind
|
kind?: MuseumPoiKind
|
||||||
hallId?: string
|
hallId?: string
|
||||||
@@ -180,10 +182,21 @@ export interface MuseumExhibit {
|
|||||||
audioVariants?: Record<string, {
|
audioVariants?: Record<string, {
|
||||||
title?: string
|
title?: string
|
||||||
text?: string
|
text?: string
|
||||||
|
textAvailable?: boolean
|
||||||
|
textLength?: number
|
||||||
|
textHash?: string
|
||||||
audioUrl?: string
|
audioUrl?: string
|
||||||
audioDuration?: number
|
audioDuration?: number
|
||||||
|
format?: string
|
||||||
|
audioId?: string
|
||||||
|
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||||
hasText?: boolean
|
hasText?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
playable?: boolean
|
||||||
available?: boolean
|
available?: boolean
|
||||||
|
audioStatus?: string
|
||||||
|
fallback?: boolean
|
||||||
|
reason?: string
|
||||||
}>
|
}>
|
||||||
imageStatus?: string
|
imageStatus?: string
|
||||||
imageSource?: string
|
imageSource?: string
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export type PoiCategorySource = Pick<
|
|||||||
| 'sourcePlaceId'
|
| 'sourcePlaceId'
|
||||||
| 'sourceSpaceId'
|
| 'sourceSpaceId'
|
||||||
| 'sourceObjectName'
|
| 'sourceObjectName'
|
||||||
|
| 'visitorVisible'
|
||||||
>
|
>
|
||||||
|
|
||||||
const normalizeValue = (value?: string | null) => (value || '')
|
const normalizeValue = (value?: string | null) => (value || '')
|
||||||
@@ -192,6 +193,13 @@ const normalizeValue = (value?: string | null) => (value || '')
|
|||||||
.replace(/[\s-]+/g, '_')
|
.replace(/[\s-]+/g, '_')
|
||||||
.replace(/^_+|_+$/g, '')
|
.replace(/^_+|_+$/g, '')
|
||||||
|
|
||||||
|
const visitorRestrictedPlaceNamePattern = /(?:贵宾|vip|员工|职工|后勤|办公|行政|库房|仓库|机房|设备间|配电|弱电|强电|保洁|值班|消防控制|监控室|staff|employee|back[_ -]?of[_ -]?house|maintenance)/i
|
||||||
|
|
||||||
|
/** Legacy sources without an explicit flag must not expose staff-only places. */
|
||||||
|
export const isVisitorRestrictedPlaceName = (value?: string | null) => (
|
||||||
|
visitorRestrictedPlaceNamePattern.test(normalizeValue(value))
|
||||||
|
)
|
||||||
|
|
||||||
const poiSemanticAliases: Readonly<Record<string, string>> = {
|
const poiSemanticAliases: Readonly<Record<string, string>> = {
|
||||||
exhibition: 'exhibition_hall',
|
exhibition: 'exhibition_hall',
|
||||||
exhibition_hall: 'exhibition_hall',
|
exhibition_hall: 'exhibition_hall',
|
||||||
@@ -397,7 +405,7 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
|
|||||||
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
|
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
|
||||||
)
|
)
|
||||||
|
|
||||||
const hiddenVisitorPrimaryTypes = new Set([
|
const hiddenVisitorTypes = new Set([
|
||||||
'entrance_exit',
|
'entrance_exit',
|
||||||
'hall_entrance',
|
'hall_entrance',
|
||||||
'entrance_anchor',
|
'entrance_anchor',
|
||||||
@@ -406,7 +414,7 @@ const hiddenVisitorPrimaryTypes = new Set([
|
|||||||
'operation_experience'
|
'operation_experience'
|
||||||
])
|
])
|
||||||
|
|
||||||
const primaryTypeValues = (poi: PoiCategorySource) => [
|
const visitorTypeValues = (poi: PoiCategorySource) => [
|
||||||
poi.primaryCategory.id,
|
poi.primaryCategory.id,
|
||||||
poi.primaryCategory.label,
|
poi.primaryCategory.label,
|
||||||
poi.primaryCategory.iconType || ''
|
poi.primaryCategory.iconType || ''
|
||||||
@@ -414,10 +422,18 @@ const primaryTypeValues = (poi: PoiCategorySource) => [
|
|||||||
.map(normalizePoiSemanticValue)
|
.map(normalizePoiSemanticValue)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const isHiddenVisitorType = (value: string) => {
|
||||||
|
const normalizedValue = normalizePoiSemanticValue(value)
|
||||||
|
const sourceWrappedValue = normalizedValue.replace(/^(?:space|poi|facility|business)_/, '')
|
||||||
|
return hiddenVisitorTypes.has(normalizedValue)
|
||||||
|
|| hiddenVisitorTypes.has(sourceWrappedValue)
|
||||||
|
}
|
||||||
|
|
||||||
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
|
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
|
||||||
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
|
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
|
||||||
|
if (poi.visitorVisible === false) return false
|
||||||
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
|
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
|
||||||
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
|
return !visitorTypeValues(poi).some(isHiddenVisitorType)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default floor browse only contains canonical halls and valid destination spaces. */
|
/** Default floor browse only contains canonical halls and valid destination spaces. */
|
||||||
@@ -516,8 +532,6 @@ export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
|
|||||||
} else if (!hasFinitePosition(poi.positionGltf)) {
|
} else if (!hasFinitePosition(poi.positionGltf)) {
|
||||||
issues.push({ code: 'invalid-position', ...issueBase })
|
issues.push({ code: 'invalid-position', ...issueBase })
|
||||||
}
|
}
|
||||||
if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
|
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +562,6 @@ export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
|
|||||||
const inspection = inspectPoiCollection(pois)
|
const inspection = inspectPoiCollection(pois)
|
||||||
if (!inspection.issues.length && !inspection.duplicateIds.length) return
|
if (!inspection.issues.length && !inspection.duplicateIds.length) return
|
||||||
|
|
||||||
// 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。
|
// This is an aggregate development diagnostic, not a visitor-facing failure.
|
||||||
console.warn(`[POI 数据校验] ${source}`, inspection)
|
console.debug(`[POI 数据诊断] ${source}`, inspection)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface PoiSearchSelection {
|
|||||||
export interface PoiCategoryResultState extends PoiSearchContext {
|
export interface PoiCategoryResultState extends PoiSearchContext {
|
||||||
active: boolean
|
active: boolean
|
||||||
pending?: boolean
|
pending?: boolean
|
||||||
|
/** Monotonic home-result event version used to reject stale cross-component state. */
|
||||||
|
requestId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'
|
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ import {
|
|||||||
type ExplainDetailPageViewModel
|
type ExplainDetailPageViewModel
|
||||||
} from '@/view-models/explainViewModels'
|
} from '@/view-models/explainViewModels'
|
||||||
import type {
|
import type {
|
||||||
AudioPlayTargetType
|
AudioPlayTargetType,
|
||||||
|
MuseumExhibit
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import type {
|
import type {
|
||||||
AudioLanguage
|
AudioLanguage
|
||||||
@@ -193,6 +194,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
||||||
|
const detailSource = ref<MuseumExhibit | null>(null)
|
||||||
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
|
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
|
||||||
const detailStateMessage = ref('请检查链接后重试。')
|
const detailStateMessage = ref('请检查链接后重试。')
|
||||||
const globalAudioPlayer = useGlobalAudioPlayer()
|
const globalAudioPlayer = useGlobalAudioPlayer()
|
||||||
@@ -201,7 +203,6 @@ const resolvedHallId = ref('')
|
|||||||
const retryingAudio = ref(false)
|
const retryingAudio = ref(false)
|
||||||
const languageSwitchLoading = ref(false)
|
const languageSwitchLoading = ref(false)
|
||||||
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
|
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
|
||||||
let languageSwitchSequence = 0
|
|
||||||
const detailEntryRequest = ref<{
|
const detailEntryRequest = ref<{
|
||||||
exhibitId: string
|
exhibitId: string
|
||||||
targetType?: AudioPlayTargetType
|
targetType?: AudioPlayTargetType
|
||||||
@@ -217,16 +218,6 @@ const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
|
|||||||
'yue-HK': '当前语言暂无讲解词。',
|
'yue-HK': '当前语言暂无讲解词。',
|
||||||
'en-US': 'English narration text is not available yet.'
|
'en-US': 'English narration text is not available yet.'
|
||||||
})
|
})
|
||||||
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
|
|
||||||
'zh-CN': false,
|
|
||||||
'yue-HK': false,
|
|
||||||
'en-US': false
|
|
||||||
})
|
|
||||||
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
|
|
||||||
'zh-CN': false,
|
|
||||||
'yue-HK': false,
|
|
||||||
'en-US': false
|
|
||||||
})
|
|
||||||
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
|
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
|
||||||
const isLocationActionVisible = false
|
const isLocationActionVisible = false
|
||||||
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||||
@@ -416,63 +407,22 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => {
|
|||||||
...detailTextByLanguage.value,
|
...detailTextByLanguage.value,
|
||||||
[lang]: normalized || fallbackTextForLanguage(lang)
|
[lang]: normalized || fallbackTextForLanguage(lang)
|
||||||
}
|
}
|
||||||
detailTextLoadedByLanguage.value = {
|
|
||||||
...detailTextLoadedByLanguage.value,
|
|
||||||
[lang]: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({
|
const hydrateDetailTexts = (source: MuseumExhibit) => {
|
||||||
id: viewModel.id,
|
const nextTexts: Record<AudioLanguage, string> = {
|
||||||
name: viewModel.title,
|
'zh-CN': fallbackTextForLanguage('zh-CN'),
|
||||||
hallId: viewModel.hallId,
|
'yue-HK': fallbackTextForLanguage('yue-HK'),
|
||||||
hallName: viewModel.hallName,
|
'en-US': fallbackTextForLanguage('en-US')
|
||||||
floorId: viewModel.floorId,
|
}
|
||||||
floorLabel: viewModel.floorLabel,
|
|
||||||
image: viewModel.coverImages[0],
|
;(['zh-CN', 'yue-HK', 'en-US'] as AudioLanguage[]).forEach((lang) => {
|
||||||
description: viewModel.summary,
|
const variant = source.audioVariants?.[lang]
|
||||||
guideText: viewModel.summary,
|
const text = variant?.text || (source.audioLanguage === lang ? source.guideText : undefined)
|
||||||
audioLanguage: viewModel.audio.language,
|
nextTexts[lang] = text?.trim() || fallbackTextForLanguage(lang)
|
||||||
audioHasText: viewModel.audio.hasText,
|
|
||||||
playTargetType: viewModel.audio.playTargetType,
|
|
||||||
playTargetId: viewModel.audio.playTargetId
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadFullDetailTextForLanguage = async (
|
detailTextByLanguage.value = nextTexts
|
||||||
request: NonNullable<typeof detailEntryRequest.value>,
|
|
||||||
lang: AudioLanguage,
|
|
||||||
preferredViewModel?: ExplainDetailPageViewModel
|
|
||||||
) => {
|
|
||||||
detailTextLoadingByLanguage.value = {
|
|
||||||
...detailTextLoadingByLanguage.value,
|
|
||||||
[lang]: true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
|
|
||||||
...request,
|
|
||||||
lang
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (viewModel.audio.hasText) {
|
|
||||||
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
|
|
||||||
if (selection.available) {
|
|
||||||
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
|
|
||||||
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDetailText(lang, viewModel.body || viewModel.summary)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('讲解正文加载失败:', lang, error)
|
|
||||||
applyDetailText(lang)
|
|
||||||
} finally {
|
|
||||||
detailTextLoadingByLanguage.value = {
|
|
||||||
...detailTextLoadingByLanguage.value,
|
|
||||||
[lang]: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadExplainDetail = async (
|
const loadExplainDetail = async (
|
||||||
@@ -485,6 +435,8 @@ const loadExplainDetail = async (
|
|||||||
lang
|
lang
|
||||||
})
|
})
|
||||||
|
|
||||||
|
detailSource.value = exhibitData
|
||||||
|
hydrateDetailTexts(exhibitData)
|
||||||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
||||||
const resolvedLanguage = resolveSupportedDetailLanguage(lang)
|
const resolvedLanguage = resolveSupportedDetailLanguage(lang)
|
||||||
selectedAudioLanguage.value = resolvedLanguage
|
selectedAudioLanguage.value = resolvedLanguage
|
||||||
@@ -494,12 +446,13 @@ const loadExplainDetail = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedLanguage !== lang) {
|
if (resolvedLanguage !== lang) {
|
||||||
|
const localizedExhibit = explainUseCase.selectExplainDetailLanguage(exhibitData, resolvedLanguage)
|
||||||
|
detailSource.value = localizedExhibit
|
||||||
|
exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
|
||||||
|
selectedAudioLanguage.value = resolvedLanguage
|
||||||
replaceDetailRouteLanguage(resolvedLanguage)
|
replaceDetailRouteLanguage(resolvedLanguage)
|
||||||
await loadExplainDetail(request, resolvedLanguage)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
|
|
||||||
detailState.value = 'ready'
|
detailState.value = 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,9 +571,7 @@ const splitDetailTextIntoParagraphs = (text: string): string[] => {
|
|||||||
|
|
||||||
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
|
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
|
||||||
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
|
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
|
||||||
const currentDetailTextLoading = computed(() => (
|
const currentDetailTextLoading = computed(() => languageSwitchLoading.value)
|
||||||
languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
|
|
||||||
))
|
|
||||||
const detailLoadingMessage = computed(() => (
|
const detailLoadingMessage = computed(() => (
|
||||||
languageSwitchLoading.value
|
languageSwitchLoading.value
|
||||||
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
|
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
|
||||||
@@ -648,64 +599,42 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|
|||||||
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
|
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
|
||||||
) return
|
) return
|
||||||
|
|
||||||
|
const source = detailSource.value
|
||||||
|
if (!source) return
|
||||||
|
|
||||||
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
|
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
|
||||||
const request = detailEntryRequest.value
|
const localizedExhibit = explainUseCase.selectExplainDetailLanguage(source, lang)
|
||||||
const requestSequence = ++languageSwitchSequence
|
const localizedViewModel = toExplainDetailPageViewModel(localizedExhibit)
|
||||||
selectedAudioLanguage.value = lang
|
selectedAudioLanguage.value = lang
|
||||||
|
detailSource.value = localizedExhibit
|
||||||
|
exhibit.value = localizedViewModel
|
||||||
|
applyDetailText(lang, localizedExhibit.audioText || localizedExhibit.guideText || localizedViewModel.body)
|
||||||
replaceDetailRouteLanguage(lang)
|
replaceDetailRouteLanguage(lang)
|
||||||
|
|
||||||
|
if (localizedExhibit.hallId) {
|
||||||
|
resolvedHallId.value = localizedExhibit.hallId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldSyncAudio || globalAudioPlayer.currentSource.value?.lang === lang) return
|
||||||
|
|
||||||
languageSwitchLoading.value = true
|
languageSwitchLoading.value = true
|
||||||
|
|
||||||
const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang
|
|
||||||
? globalAudioPlayer.switchLanguage(lang)
|
|
||||||
: Promise.resolve(true)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!request) {
|
const nextAudio = localizedViewModel.audio.url
|
||||||
applyDetailText(lang)
|
? {
|
||||||
await audioSwitch
|
id: `stop-${localizedViewModel.audio.playTargetType || 'ITEM'}-${localizedViewModel.audio.playTargetId || localizedViewModel.id}-${lang}`,
|
||||||
return
|
name: localizedViewModel.title,
|
||||||
}
|
audioUrl: localizedViewModel.audio.url,
|
||||||
|
image: heroImage.value,
|
||||||
const exhibitData = await explainUseCase.enterExplainDetail({
|
duration: localizedViewModel.audio.duration,
|
||||||
...request,
|
|
||||||
lang
|
|
||||||
})
|
|
||||||
if (requestSequence !== languageSwitchSequence) return
|
|
||||||
|
|
||||||
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
|
|
||||||
exhibit.value = nextViewModel
|
|
||||||
if (exhibitData.hallId) {
|
|
||||||
resolvedHallId.value = exhibitData.hallId
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
|
|
||||||
await audioSwitch
|
|
||||||
} catch (error) {
|
|
||||||
if (requestSequence !== languageSwitchSequence) return
|
|
||||||
|
|
||||||
console.warn('讲解语言切换失败:', lang, error)
|
|
||||||
exhibit.value = {
|
|
||||||
...exhibit.value,
|
|
||||||
audio: {
|
|
||||||
...exhibit.value.audio,
|
|
||||||
status: 'unavailable',
|
|
||||||
url: undefined,
|
|
||||||
duration: undefined,
|
|
||||||
language: lang,
|
language: lang,
|
||||||
unavailableReason: '讲解服务暂不可用,请稍后重试'
|
supportedLanguages: supportedDetailLanguages.value
|
||||||
}
|
}
|
||||||
}
|
: null
|
||||||
applyDetailText(lang)
|
await globalAudioPlayer.switchLanguage(lang, nextAudio)
|
||||||
uni.showToast({
|
|
||||||
title: '讲解语言切换失败,请稍后重试',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSequence === languageSwitchSequence) {
|
|
||||||
languageSwitchLoading.value = false
|
languageSwitchLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => globalAudioPlayer.currentSource.value,
|
() => globalAudioPlayer.currentSource.value,
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ const syncPageTitle = (title: string) => {
|
|||||||
if (typeof document !== 'undefined') document.title = title
|
if (typeof document !== 'undefined') document.title = title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decodeRouteParam = (value: string) => {
|
||||||
|
let decoded = value
|
||||||
|
// H5 hash routes embedded in web-view can add multiple URL-encoding layers.
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
try {
|
||||||
|
const next = decodeURIComponent(decoded)
|
||||||
|
if (next === decoded) break
|
||||||
|
decoded = next
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
const loadPage = async (pageNo: number, replace: boolean) => {
|
const loadPage = async (pageNo: number, replace: boolean) => {
|
||||||
const hallId = selectedExplainHallId.value
|
const hallId = selectedExplainHallId.value
|
||||||
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
|
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
|
||||||
@@ -115,8 +130,8 @@ const loadNextPage = () => void loadPage(nextPageNo.value, false)
|
|||||||
onLoad((options: Record<string, string | string[] | undefined> = {}) => {
|
onLoad((options: Record<string, string | string[] | undefined> = {}) => {
|
||||||
const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
|
const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
|
||||||
selectedExplainHallId.value = first(options.hallId) || ''
|
selectedExplainHallId.value = first(options.hallId) || ''
|
||||||
selectedExplainHallName.value = first(options.hallName) || '讲解对象'
|
selectedExplainHallName.value = decodeRouteParam(first(options.hallName) || '讲解对象')
|
||||||
syncPageTitle('讲解对象')
|
syncPageTitle(selectedExplainHallName.value)
|
||||||
if (!selectedExplainHallId.value) {
|
if (!selectedExplainHallId.value) {
|
||||||
explainError.value = '缺少展厅参数,请返回展厅列表后重试'
|
explainError.value = '缺少展厅参数,请返回展厅列表后重试'
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ const requestedFloorId = ref('')
|
|||||||
const requestedFloorLabel = ref('')
|
const requestedFloorLabel = ref('')
|
||||||
const loadingFloorId = ref('')
|
const loadingFloorId = ref('')
|
||||||
const renderedFloorId = ref('')
|
const renderedFloorId = ref('')
|
||||||
|
// Only GuideMapShell's floor-change confirms that the Three.js floor is usable.
|
||||||
|
const committedFloorId = ref('')
|
||||||
const failedFloorId = ref('')
|
const failedFloorId = ref('')
|
||||||
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
|
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
|
||||||
const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(null)
|
const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(null)
|
||||||
@@ -432,6 +434,8 @@ const homeSearchExpanded = ref(false)
|
|||||||
const homeCategoryModeActive = ref(false)
|
const homeCategoryModeActive = ref(false)
|
||||||
const searchVisiblePoiIds = ref<string[] | null>(null)
|
const searchVisiblePoiIds = ref<string[] | null>(null)
|
||||||
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
||||||
|
const homeSearchMapState = ref<PoiCategoryResultState | null>(null)
|
||||||
|
let latestHomeSearchMapRequestId = 0
|
||||||
type PoiSearchOverlayHistoryState = {
|
type PoiSearchOverlayHistoryState = {
|
||||||
museumGuideOverlay?: 'poi-search'
|
museumGuideOverlay?: 'poi-search'
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
@@ -853,6 +857,9 @@ const handleTabChange = (tabId: GuideTopTab) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tabId !== currentTab.value) {
|
||||||
|
invalidateHomeSearchFloorCommit()
|
||||||
|
}
|
||||||
currentTab.value = tabId
|
currentTab.value = tabId
|
||||||
syncTopTabToUrl(tabId)
|
syncTopTabToUrl(tabId)
|
||||||
loadTopTabData(tabId)
|
loadTopTabData(tabId)
|
||||||
@@ -875,6 +882,7 @@ const syncTopTabFromHash = () => {
|
|||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
loadTopTabData(tab)
|
loadTopTabData(tab)
|
||||||
if (tabChanged) {
|
if (tabChanged) {
|
||||||
|
invalidateHomeSearchFloorCommit()
|
||||||
showRoutePlanner.value = false
|
showRoutePlanner.value = false
|
||||||
isSimulatingRoute.value = false
|
isSimulatingRoute.value = false
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
@@ -1000,11 +1008,19 @@ const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLab
|
|||||||
requestedFloorLabel.value = floorLabel
|
requestedFloorLabel.value = floorLabel
|
||||||
loadingFloorId.value = floorId
|
loadingFloorId.value = floorId
|
||||||
failedFloorId.value = ''
|
failedFloorId.value = ''
|
||||||
|
if (homeSearchMapState.value?.active) {
|
||||||
|
searchVisiblePoiIds.value = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFloorChange = (floorId: string) => {
|
const handleFloorChange = (floorId: string) => {
|
||||||
activeGuideFloor.value = floorId
|
activeGuideFloor.value = floorId
|
||||||
renderedFloorId.value = floorId
|
renderedFloorId.value = floorId
|
||||||
|
committedFloorId.value = floorId
|
||||||
|
if (requestedFloorId.value === floorId) {
|
||||||
|
requestedFloorId.value = ''
|
||||||
|
requestedFloorLabel.value = ''
|
||||||
|
}
|
||||||
if (loadingFloorId.value === floorId) {
|
if (loadingFloorId.value === floorId) {
|
||||||
loadingFloorId.value = ''
|
loadingFloorId.value = ''
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1030,7 @@ const handleFloorChange = (floorId: string) => {
|
|||||||
indoorView.value = 'floor'
|
indoorView.value = 'floor'
|
||||||
selectedGuidePoi.value = null
|
selectedGuidePoi.value = null
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
|
syncHomeSearchMarkersForCommittedFloor(floorId)
|
||||||
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200)
|
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200)
|
||||||
console.log('楼层渲染完成:', floorId)
|
console.log('楼层渲染完成:', floorId)
|
||||||
}
|
}
|
||||||
@@ -1023,6 +1040,13 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo
|
|||||||
loadingFloorId.value = ''
|
loadingFloorId.value = ''
|
||||||
}
|
}
|
||||||
failedFloorId.value = floorId
|
failedFloorId.value = floorId
|
||||||
|
if (requestedFloorId.value === floorId) {
|
||||||
|
requestedFloorId.value = ''
|
||||||
|
requestedFloorLabel.value = ''
|
||||||
|
}
|
||||||
|
if (homeSearchMapState.value?.floorId === floorId) {
|
||||||
|
searchVisiblePoiIds.value = []
|
||||||
|
}
|
||||||
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
|
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
|
||||||
console.warn('楼层渲染失败:', floorId)
|
console.warn('楼层渲染失败:', floorId)
|
||||||
}
|
}
|
||||||
@@ -1030,6 +1054,8 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo
|
|||||||
const handleIndoorViewChange = (view: GuideIndoorView) => {
|
const handleIndoorViewChange = (view: GuideIndoorView) => {
|
||||||
indoorView.value = view
|
indoorView.value = view
|
||||||
if (view !== 'floor') {
|
if (view !== 'floor') {
|
||||||
|
committedFloorId.value = ''
|
||||||
|
syncHomeSearchMarkersForCommittedFloor()
|
||||||
selectedGuidePoi.value = null
|
selectedGuidePoi.value = null
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1071,8 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
|||||||
console.log('自动切换视图:', event)
|
console.log('自动切换视图:', event)
|
||||||
indoorView.value = event.to
|
indoorView.value = event.to
|
||||||
if (event.to !== 'floor') {
|
if (event.to !== 'floor') {
|
||||||
|
committedFloorId.value = ''
|
||||||
|
syncHomeSearchMarkersForCommittedFloor()
|
||||||
selectedGuidePoi.value = null
|
selectedGuidePoi.value = null
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
}
|
}
|
||||||
@@ -1053,6 +1081,11 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
|||||||
|
|
||||||
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
|
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
|
||||||
const readyFloorId = event.floorId || activeGuideFloor.value
|
const readyFloorId = event.floorId || activeGuideFloor.value
|
||||||
|
indoorView.value = event.view
|
||||||
|
if (event.view !== 'floor') {
|
||||||
|
committedFloorId.value = ''
|
||||||
|
syncHomeSearchMarkersForCommittedFloor()
|
||||||
|
}
|
||||||
if (event.view === 'overview' && readyFloorId) {
|
if (event.view === 'overview' && readyFloorId) {
|
||||||
activeGuideFloor.value = readyFloorId
|
activeGuideFloor.value = readyFloorId
|
||||||
renderedFloorId.value = readyFloorId
|
renderedFloorId.value = readyFloorId
|
||||||
@@ -1092,6 +1125,7 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => {
|
|||||||
isSimulatingRoute.value = false
|
isSimulatingRoute.value = false
|
||||||
activeGuideFloor.value = poi.floorId
|
activeGuideFloor.value = poi.floorId
|
||||||
renderedFloorId.value = poi.floorId
|
renderedFloorId.value = poi.floorId
|
||||||
|
committedFloorId.value = poi.floorId
|
||||||
showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
|
showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1519,6 +1553,7 @@ const resetGuideModelToFloorBaseline = async ({
|
|||||||
if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return
|
if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return
|
||||||
if (resetResult === 'applied') {
|
if (resetResult === 'applied') {
|
||||||
renderedFloorId.value = activeGuideFloor.value
|
renderedFloorId.value = activeGuideFloor.value
|
||||||
|
committedFloorId.value = activeGuideFloor.value
|
||||||
guideModelState.completeViewBaselineReset(resetRequest)
|
guideModelState.completeViewBaselineReset(resetRequest)
|
||||||
console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason })
|
console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason })
|
||||||
} else if (resetResult === 'not-ready' || resetResult === undefined) {
|
} else if (resetResult === 'not-ready' || resetResult === undefined) {
|
||||||
@@ -1589,12 +1624,20 @@ const clearHomeSearchMapState = () => {
|
|||||||
homeCategoryModeActive.value = false
|
homeCategoryModeActive.value = false
|
||||||
searchVisiblePoiIds.value = null
|
searchVisiblePoiIds.value = null
|
||||||
searchTargetFocusRequest.value = null
|
searchTargetFocusRequest.value = null
|
||||||
|
homeSearchMapState.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidateHomeSearchFloorCommit = () => {
|
||||||
|
clearHomeSearchMapState()
|
||||||
|
committedFloorId.value = ''
|
||||||
|
indoorView.value = 'overview'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHomeCategoryModeChange = (active: boolean) => {
|
const handleHomeCategoryModeChange = (active: boolean) => {
|
||||||
homeCategoryModeActive.value = active
|
homeCategoryModeActive.value = active
|
||||||
if (!active) {
|
if (!active) {
|
||||||
searchVisiblePoiIds.value = null
|
searchVisiblePoiIds.value = null
|
||||||
|
homeSearchMapState.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1604,19 +1647,72 @@ const handleHomeCategoryModeChange = (active: boolean) => {
|
|||||||
selectedGuidePoi.value = null
|
selectedGuidePoi.value = null
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
is3DMode.value = true
|
is3DMode.value = true
|
||||||
indoorView.value = 'floor'
|
searchVisiblePoiIds.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
|
const isHomeSearchFloorCommitted = (floorId: string) => (
|
||||||
if (!state.active) {
|
Boolean(floorId)
|
||||||
searchVisiblePoiIds.value = null
|
&& committedFloorId.value === floorId
|
||||||
|
&& indoorView.value === 'floor'
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestHomeSearchFloor = (floorId: string, floorLabel = '') => {
|
||||||
|
if (!floorId || requestedFloorId.value === floorId || loadingFloorId.value === floorId) return
|
||||||
|
|
||||||
|
requestedFloorId.value = floorId
|
||||||
|
requestedFloorLabel.value = floorLabel || getGuideFloorLabel(floorId)
|
||||||
|
loadingFloorId.value = floorId
|
||||||
|
failedFloorId.value = ''
|
||||||
|
void Promise.resolve(guideMapShellRef.value?.switchFloor?.(floorId))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('快捷入口楼层切换失败:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncHomeSearchMarkersForCommittedFloor = (floorId = committedFloorId.value) => {
|
||||||
|
const state = homeSearchMapState.value
|
||||||
|
if (
|
||||||
|
!state?.active
|
||||||
|
|| state.pending
|
||||||
|
|| state.floorId !== floorId
|
||||||
|
|| indoorView.value !== 'floor'
|
||||||
|
) {
|
||||||
|
searchVisiblePoiIds.value = state?.active ? [] : null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
searchVisiblePoiIds.value = [...state.visiblePoiIds]
|
searchVisiblePoiIds.value = [...state.visiblePoiIds]
|
||||||
if (state.floorId && !state.pending) {
|
|
||||||
activeGuideFloor.value = state.floorId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
|
||||||
|
if (
|
||||||
|
typeof state.requestId === 'number'
|
||||||
|
&& state.requestId < latestHomeSearchMapRequestId
|
||||||
|
) return
|
||||||
|
if (typeof state.requestId === 'number') {
|
||||||
|
latestHomeSearchMapRequestId = state.requestId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.active) {
|
||||||
|
clearHomeSearchMapState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
homeSearchMapState.value = {
|
||||||
|
...state,
|
||||||
|
visiblePoiIds: [...state.visiblePoiIds]
|
||||||
|
}
|
||||||
|
if (state.pending) {
|
||||||
|
searchVisiblePoiIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isHomeSearchFloorCommitted(state.floorId)) {
|
||||||
|
searchVisiblePoiIds.value = []
|
||||||
|
requestHomeSearchFloor(state.floorId, state.floorLabel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHomeSearchMarkersForCommittedFloor(state.floorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
|
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
|
||||||
@@ -1672,6 +1768,7 @@ const handleExplainHallClick = async (hallId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExplainBack = () => {
|
const handleExplainBack = () => {
|
||||||
|
invalidateHomeSearchFloorCommit()
|
||||||
currentTab.value = 'guide'
|
currentTab.value = 'guide'
|
||||||
syncTopTabToUrl('guide')
|
syncTopTabToUrl('guide')
|
||||||
loadTopTabData('guide')
|
loadTopTabData('guide')
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const reasonMessageMap: Record<string, string> = {
|
|||||||
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
||||||
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
||||||
NO_TEXT: '当前语言暂无讲解词',
|
NO_TEXT: '当前语言暂无讲解词',
|
||||||
|
LANGUAGE_DISABLED: '该语言暂未开放',
|
||||||
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
||||||
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
||||||
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import {
|
|||||||
} from '@/domain/guideFloor'
|
} from '@/domain/guideFloor'
|
||||||
import {
|
import {
|
||||||
buildSgsFloorAliases,
|
buildSgsFloorAliases,
|
||||||
createSgsHallPoiDiagnostics,
|
formatSgsFloorLabel
|
||||||
formatSgsFloorLabel,
|
|
||||||
toMuseumHallPoisFromSgs,
|
|
||||||
toMuseumPoiFromSgs
|
|
||||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||||
import {
|
import {
|
||||||
defaultSgsSdkApiProvider,
|
defaultSgsSdkApiProvider,
|
||||||
@@ -47,6 +44,9 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
GuideRepository
|
GuideRepository
|
||||||
} from '@/repositories/GuideRepository'
|
} from '@/repositories/GuideRepository'
|
||||||
|
import {
|
||||||
|
canonicalizeVisitorSearchPois
|
||||||
|
} from '@/repositories/GuideRepository'
|
||||||
import {
|
import {
|
||||||
guideRepository
|
guideRepository
|
||||||
} from '@/repositories/createGuideRepository'
|
} from '@/repositories/createGuideRepository'
|
||||||
@@ -84,37 +84,6 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
|
|
||||||
|
|
||||||
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
|
|
||||||
// A facility can be located inside the same spatial area as other facilities.
|
|
||||||
// That relationship must not collapse distinct visitor markers (for example,
|
|
||||||
// a restroom and an elevator in one service zone). Space identity is only a
|
|
||||||
// canonical-place key for halls, spaces, and business-place representations.
|
|
||||||
const canDedupeBySpace = poi.kind === 'hall'
|
|
||||||
|| poi.kind === 'space'
|
|
||||||
|| poi.primaryCategory === 'business_poi'
|
|
||||||
const stableKeys = [
|
|
||||||
poi.id ? `id:${poi.id}` : '',
|
|
||||||
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
|
|
||||||
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
|
|
||||||
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
if (stableKeys.length) return stableKeys
|
|
||||||
return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
return pois.filter((poi) => {
|
|
||||||
const keys = getRenderPoiDedupeKeys(poi)
|
|
||||||
if (!keys.length || keys.some((key) => seen.has(key))) return false
|
|
||||||
keys.forEach((key) => seen.add(key))
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const countByValue = <T>(
|
const countByValue = <T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
selector: (item: T) => string | number | null | undefined
|
selector: (item: T) => string | number | null | undefined
|
||||||
@@ -124,17 +93,6 @@ const countByValue = <T>(
|
|||||||
return counts
|
return counts
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
const countDroppedRenderPoiCategories = (
|
|
||||||
sourcePois: GuideRenderPoi[],
|
|
||||||
keptPois: GuideRenderPoi[]
|
|
||||||
) => {
|
|
||||||
const keptIds = new Set(keptPois.map((poi) => poi.id))
|
|
||||||
return countByValue(
|
|
||||||
sourcePois.filter((poi) => !keptIds.has(poi.id)),
|
|
||||||
(poi) => poi.primaryCategory
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
|
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
|
||||||
|
|
||||||
const resolveSgsAssetUrl = (url?: string | null) => {
|
const resolveSgsAssetUrl = (url?: string | null) => {
|
||||||
@@ -234,20 +192,6 @@ const summarizeYValues = (pois: GuideRenderPoi[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
|
|
||||||
const values = pois
|
|
||||||
.map((poi) => poi.positionGltf?.[1])
|
|
||||||
.filter((value): value is number => Number.isFinite(value))
|
|
||||||
.sort((left, right) => left - right)
|
|
||||||
|
|
||||||
if (!values.length) return undefined
|
|
||||||
|
|
||||||
const middle = Math.floor(values.length / 2)
|
|
||||||
return values.length % 2
|
|
||||||
? values[middle]
|
|
||||||
: (values[middle - 1] + values[middle]) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
|
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
|
||||||
[
|
[
|
||||||
String(floor.floorId),
|
String(floor.floorId),
|
||||||
@@ -442,7 +386,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
|||||||
|
|
||||||
const resolvedFloorId = String(matchedFloor.floorId)
|
const resolvedFloorId = String(matchedFloor.floorId)
|
||||||
const loadFloorData = async <T>(
|
const loadFloorData = async <T>(
|
||||||
endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints',
|
endpoint: 'guideSearch',
|
||||||
loader: () => Promise<T[]>
|
loader: () => Promise<T[]>
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -457,88 +401,36 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([
|
const visitorSearchPois = await loadFloorData(
|
||||||
loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)),
|
'guideSearch',
|
||||||
loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)),
|
() => this.guide.searchPois('', resolvedFloorId)
|
||||||
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)),
|
)
|
||||||
loadFloorData('guidePois', () => this.guide.listPois()),
|
const canonicalPois = canonicalizeVisitorSearchPois(visitorSearchPois)
|
||||||
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
|
const renderPois = canonicalPois
|
||||||
])
|
.filter((poi) => poi.floorId === resolvedFloorId)
|
||||||
const ordinaryPois = pois
|
|
||||||
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
|
|
||||||
.filter(isVisitorSearchPoi)
|
|
||||||
.map(toSgsRenderPoi)
|
|
||||||
const repositoryBusinessPois = guidePois
|
|
||||||
.filter((poi) => (
|
|
||||||
poi.floorId === resolvedFloorId
|
|
||||||
&& poi.primaryCategory.id === 'business_poi'
|
|
||||||
&& isVisitorSearchPoi(poi)
|
|
||||||
))
|
|
||||||
.map(toGuideRenderPoi)
|
.map(toGuideRenderPoi)
|
||||||
const repositorySpacePois = guideSpacePoints
|
|
||||||
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
|
|
||||||
.map(toGuideRenderPoi)
|
|
||||||
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
|
|
||||||
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
|
|
||||||
fallbackY: floorPoiMedianY
|
|
||||||
})
|
|
||||||
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
|
|
||||||
|
|
||||||
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
|
|
||||||
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
|
|
||||||
floorId: resolvedFloorId,
|
|
||||||
...hallDiagnostics
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const hallPois = museumHallPois
|
|
||||||
.filter(isVisitorSearchPoi)
|
|
||||||
.map(toSgsRenderPoi)
|
|
||||||
const adaptedPois = dedupeRenderPoisById([
|
|
||||||
...hallPois,
|
|
||||||
...ordinaryPois,
|
|
||||||
...repositorySpacePois,
|
|
||||||
...repositoryBusinessPois
|
|
||||||
])
|
|
||||||
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
|
|
||||||
const renderPois = floorMatchedPois
|
|
||||||
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||||
|
|
||||||
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
|
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
|
||||||
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
|
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
|
||||||
floorId: resolvedFloorId,
|
floorId: resolvedFloorId,
|
||||||
floorCode: matchedFloor.floorCode,
|
floorCode: matchedFloor.floorCode,
|
||||||
rawPoiCount: pois.length,
|
canonicalPoiCount: canonicalPois.length,
|
||||||
rawRepositoryBusinessPoiCount: repositoryBusinessPois.length,
|
canonicalCategoryCounts: countByValue(canonicalPois, (poi) => poi.primaryCategory.id),
|
||||||
rawRepositorySpacePoiCount: repositorySpacePois.length,
|
canonicalPoiCategoryCount: canonicalPois.filter((poi) => poi.primaryCategory.id === 'poi').length,
|
||||||
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
|
|
||||||
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
|
|
||||||
adaptedPoiCount: adaptedPois.length,
|
|
||||||
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
|
|
||||||
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
|
||||||
renderPoiCount: renderPois.length,
|
renderPoiCount: renderPois.length,
|
||||||
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length
|
||||||
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
|
|
||||||
})
|
})
|
||||||
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
|
logSgsGuideModelDiagnostics('floor render POI diagnostics', {
|
||||||
floorId: resolvedFloorId,
|
floorId: resolvedFloorId,
|
||||||
floorCode: matchedFloor.floorCode,
|
floorCode: matchedFloor.floorCode,
|
||||||
poiCount: renderPois.length,
|
poiCount: renderPois.length,
|
||||||
hallPoiCount: renderHallPois.length,
|
hallPoiCount: renderHallPois.length,
|
||||||
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||||
poiY: summarizeYValues(renderPois),
|
poiY: summarizeYValues(renderPois),
|
||||||
hallPoiY: summarizeYValues(renderHallPois),
|
hallPoiY: summarizeYValues(renderHallPois)
|
||||||
fallbackHallY: floorPoiMedianY ?? null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) {
|
|
||||||
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
|
|
||||||
floorId: resolvedFloorId,
|
|
||||||
...hallDiagnostics,
|
|
||||||
renderPoiCount: renderPois.length
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderPois
|
return renderPois
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,17 @@ const isVisitorSearchResult = (poi: MuseumPoi) => (
|
|||||||
&& hasRenderablePosition(poi)
|
&& hasRenderablePosition(poi)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Explicitly hidden source records must still participate in canonical-place
|
||||||
|
// merging so a private space cannot leak through an unflagged POI duplicate.
|
||||||
|
const isCanonicalSearchCandidate = (poi: MuseumPoi) => (
|
||||||
|
isPoiOnIndoorNavigableFloor(poi)
|
||||||
|
&& hasRenderablePosition(poi)
|
||||||
|
&& isVisitorSearchPoi({
|
||||||
|
...poi,
|
||||||
|
visitorVisible: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const normalizePlaceName = (value: string) => value
|
const normalizePlaceName = (value: string) => value
|
||||||
.trim()
|
.trim()
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
@@ -188,6 +199,11 @@ const canonicalMergeKeys = (poi: MuseumPoi) => {
|
|||||||
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
|
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourcePlaceId = poi.sourcePlaceId?.trim()
|
||||||
|
if (sourcePlaceId) {
|
||||||
|
keys.push(`place:${poi.floorId}:${categoryId || 'other'}:${sourcePlaceId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedName = normalizePlaceName(poi.name)
|
const normalizedName = normalizePlaceName(poi.name)
|
||||||
const location = positionKey(poi)
|
const location = positionKey(poi)
|
||||||
// A space, regular POI, and business POI can describe the same visitor
|
// A space, regular POI, and business POI can describe the same visitor
|
||||||
@@ -246,7 +262,10 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
|
|||||||
spaceId: primary.spaceId || secondary.spaceId,
|
spaceId: primary.spaceId || secondary.spaceId,
|
||||||
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
|
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
|
||||||
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
|
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
|
||||||
entrances: primary.entrances || secondary.entrances
|
entrances: primary.entrances || secondary.entrances,
|
||||||
|
visitorVisible: primary.visitorVisible === false || secondary.visitorVisible === false
|
||||||
|
? false
|
||||||
|
: primary.visitorVisible ?? secondary.visitorVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +273,11 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
|
|||||||
* Search queries return a single map-renderable representation of each real
|
* Search queries return a single map-renderable representation of each real
|
||||||
* visitor place. Source collections remain intact for the renderer itself.
|
* visitor place. Source collections remain intact for the renderer itself.
|
||||||
*/
|
*/
|
||||||
const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
|
export const canonicalizeVisitorSearchPois = (pois: MuseumPoi[]) => {
|
||||||
const canonicalByKey = new Map<string, MuseumPoi>()
|
const canonicalByKey = new Map<string, MuseumPoi>()
|
||||||
|
|
||||||
pois
|
pois
|
||||||
.filter(isVisitorSearchResult)
|
.filter(isCanonicalSearchCandidate)
|
||||||
.forEach((poi) => {
|
.forEach((poi) => {
|
||||||
const keys = canonicalMergeKeys(poi)
|
const keys = canonicalMergeKeys(poi)
|
||||||
if (!keys.length) keys.push(`poi:${poi.id}`)
|
if (!keys.length) keys.push(`poi:${poi.id}`)
|
||||||
@@ -282,10 +301,11 @@ const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return dedupePoisById(Array.from(canonicalByKey.values()))
|
return dedupePoisById(Array.from(canonicalByKey.values()))
|
||||||
|
.filter(isVisitorSearchResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
|
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
|
||||||
mergeCanonicalSearchPois([
|
canonicalizeVisitorSearchPois([
|
||||||
...pois,
|
...pois,
|
||||||
...spacePoints
|
...spacePoints
|
||||||
])
|
])
|
||||||
@@ -426,7 +446,7 @@ export class StaticGuideRepository implements GuideRepository {
|
|||||||
this.listPois(),
|
this.listPois(),
|
||||||
this.listSpacePoints()
|
this.listSpacePoints()
|
||||||
])
|
])
|
||||||
this.visitorSearchPoolCache = mergeCanonicalSearchPois([
|
this.visitorSearchPoolCache = canonicalizeVisitorSearchPois([
|
||||||
...pois,
|
...pois,
|
||||||
...spacePoints
|
...spacePoints
|
||||||
])
|
])
|
||||||
@@ -470,8 +490,8 @@ export class StaticGuideRepository implements GuideRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPoiById(id: string) {
|
async getPoiById(id: string) {
|
||||||
const pois = await this.listPois()
|
const visitorPois = (await this.listPois()).filter(isVisitorSearchPoi)
|
||||||
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id)
|
return visitorPois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(visitorPois, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchPois(keyword = '', floorId?: string) {
|
async searchPois(keyword = '', floorId?: string) {
|
||||||
@@ -735,7 +755,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
|
|||||||
sourceFailures.push(error)
|
sourceFailures.push(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchPool = mergeCanonicalSearchPois([
|
const searchPool = canonicalizeVisitorSearchPois([
|
||||||
...pois,
|
...pois,
|
||||||
...spacePoints
|
...spacePoints
|
||||||
])
|
])
|
||||||
@@ -796,6 +816,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
|
|||||||
])
|
])
|
||||||
const direct = directPois
|
const direct = directPois
|
||||||
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
|
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
|
||||||
|
.filter(isVisitorSearchPoi)
|
||||||
.find((poi) => poi.id === normalizedId || getPoiSourceLookupIdentity(normalizedId) === poi.spaceId || getPoiSourceLookupIdentity(normalizedId) === poi.sourceSpaceId)
|
.find((poi) => poi.id === normalizedId || getPoiSourceLookupIdentity(normalizedId) === poi.spaceId || getPoiSourceLookupIdentity(normalizedId) === poi.sourceSpaceId)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -183,21 +183,29 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl)
|
||||||
|
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
|
||||||
|
const guideText = variant.text
|
||||||
|
|| (language === 'yue-HK' ? mandarinText : undefined)
|
||||||
|
|| `${this.languageLabel(language)}讲解词暂未配置。`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...exhibit,
|
...exhibit,
|
||||||
guideTitle: variant.title || exhibit.guideTitle,
|
guideTitle: variant.title || exhibit.guideTitle,
|
||||||
guideText: language === 'yue-HK'
|
guideText,
|
||||||
? mandarinText || '当前讲解词暂未配置。'
|
audioUrl: playable ? variant.audioUrl : undefined,
|
||||||
: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`,
|
|
||||||
audioUrl: variant.audioUrl,
|
|
||||||
audioDuration: variant.audioDuration,
|
audioDuration: variant.audioDuration,
|
||||||
audioLanguage: language,
|
audioLanguage: language,
|
||||||
audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true,
|
audioHasText: textAvailable,
|
||||||
audioAvailable: variant.available === true && Boolean(variant.audioUrl),
|
audioText: variant.text || undefined,
|
||||||
audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING',
|
audioTextLength: variant.textLength,
|
||||||
audioUnavailableReason: variant.available && variant.audioUrl
|
audioTextHash: variant.textHash,
|
||||||
|
audioNarrationTier: variant.narrationTier,
|
||||||
|
audioAvailable: playable,
|
||||||
|
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
|
||||||
|
audioUnavailableReason: playable
|
||||||
? undefined
|
? undefined
|
||||||
: `当前讲解暂无${this.languageLabel(language)}音频`,
|
: audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
|
||||||
supportedLanguages
|
supportedLanguages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +221,27 @@ export class ExplainUseCase {
|
|||||||
: undefined
|
: undefined
|
||||||
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
||||||
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
||||||
const audioAvailable = stopInfo.audioStatus === 'READY'
|
const audioVariants = Object.fromEntries(Object.values(stopInfo.languageVariants).map((variant) => [variant.lang, {
|
||||||
|
title: stopInfo.title,
|
||||||
|
text: variant.text,
|
||||||
|
textAvailable: variant.textAvailable,
|
||||||
|
textLength: variant.textLength,
|
||||||
|
textHash: variant.textHash,
|
||||||
|
audioUrl: variant.playUrl,
|
||||||
|
audioDuration: variant.duration,
|
||||||
|
format: variant.format,
|
||||||
|
audioId: variant.audioId,
|
||||||
|
narrationTier: variant.narrationTier,
|
||||||
|
hasText: variant.hasText,
|
||||||
|
enabled: variant.enabled,
|
||||||
|
playable: variant.playable,
|
||||||
|
available: variant.playable,
|
||||||
|
audioStatus: variant.audioStatus,
|
||||||
|
fallback: variant.fallback,
|
||||||
|
reason: variant.reason
|
||||||
|
}]))
|
||||||
|
const currentVariant = audioVariants[stopInfo.lang]
|
||||||
|
const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(fallback || {}),
|
...(fallback || {}),
|
||||||
@@ -235,21 +263,22 @@ export class ExplainUseCase {
|
|||||||
size: fallback?.size,
|
size: fallback?.size,
|
||||||
tags: fallback?.tags,
|
tags: fallback?.tags,
|
||||||
guideTitle: stopInfo.title || fallback?.guideTitle,
|
guideTitle: stopInfo.title || fallback?.guideTitle,
|
||||||
guideText: stopInfo.description || fallback?.guideText || fallback?.description,
|
guideText: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description,
|
||||||
audioUrl: undefined,
|
audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined,
|
||||||
audioDuration: undefined,
|
audioDuration: currentVariant?.audioDuration,
|
||||||
audioLanguage: stopInfo.lang,
|
audioLanguage: stopInfo.lang,
|
||||||
audioHasText: stopInfo.hasText,
|
audioHasText: currentVariant?.textAvailable || stopInfo.hasText,
|
||||||
audioText: undefined,
|
audioText: currentVariant?.text,
|
||||||
audioTextLength: undefined,
|
audioTextLength: currentVariant?.textLength,
|
||||||
audioTextHash: undefined,
|
audioTextHash: currentVariant?.textHash,
|
||||||
audioNarrationTier: undefined,
|
audioNarrationTier: currentVariant?.narrationTier,
|
||||||
audioUnavailableReason: audioAvailable
|
audioUnavailableReason: audioAvailable
|
||||||
? undefined
|
? undefined
|
||||||
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
|
: audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
|
||||||
audioAvailable,
|
audioAvailable,
|
||||||
audioStatus: stopInfo.audioStatus,
|
audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus,
|
||||||
supportedLanguages: stopInfo.supportedLanguages,
|
supportedLanguages: stopInfo.supportedLanguages,
|
||||||
|
audioVariants,
|
||||||
imageStatus: stopInfo.imageStatus,
|
imageStatus: stopInfo.imageStatus,
|
||||||
imageSource: stopInfo.imageSource,
|
imageSource: stopInfo.imageSource,
|
||||||
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
||||||
@@ -434,6 +463,10 @@ export class ExplainUseCase {
|
|||||||
throw stopInfoResult.error || new Error('讲解详情加载失败')
|
throw stopInfoResult.error || new Error('讲解详情加载失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) {
|
||||||
|
return this.applyLanguageVariant(exhibit, language)
|
||||||
|
}
|
||||||
|
|
||||||
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
|
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
|
||||||
const targetType = exhibit.playTargetType || 'ITEM'
|
const targetType = exhibit.playTargetType || 'ITEM'
|
||||||
const targetId = exhibit.playTargetId || exhibit.id
|
const targetId = exhibit.playTargetId || exhibit.id
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
onHideHandler: null as null | (() => void),
|
onHideHandler: null as null | (() => void),
|
||||||
onUnloadHandler: null as null | (() => void),
|
onUnloadHandler: null as null | (() => void),
|
||||||
enterExplainDetail: vi.fn(),
|
enterExplainDetail: vi.fn(),
|
||||||
|
selectExplainDetailLanguage: vi.fn(),
|
||||||
loadExplainDetailText: vi.fn(),
|
loadExplainDetailText: vi.fn(),
|
||||||
selectAudioForExplainDetail: vi.fn(),
|
selectAudioForExplainDetail: vi.fn(),
|
||||||
play: vi.fn(),
|
play: vi.fn(),
|
||||||
@@ -43,6 +44,7 @@ vi.mock('@dcloudio/uni-app', () => ({
|
|||||||
vi.mock('@/usecases/explainUseCase', () => ({
|
vi.mock('@/usecases/explainUseCase', () => ({
|
||||||
explainUseCase: {
|
explainUseCase: {
|
||||||
enterExplainDetail: mocks.enterExplainDetail,
|
enterExplainDetail: mocks.enterExplainDetail,
|
||||||
|
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
|
||||||
loadExplainDetailText: mocks.loadExplainDetailText,
|
loadExplainDetailText: mocks.loadExplainDetailText,
|
||||||
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
|
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
|
||||||
}
|
}
|
||||||
@@ -110,6 +112,10 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
audioState.currentTime.value = 0
|
audioState.currentTime.value = 0
|
||||||
audioState.duration.value = 0
|
audioState.duration.value = 0
|
||||||
mocks.enterExplainDetail.mockResolvedValue(exhibit)
|
mocks.enterExplainDetail.mockResolvedValue(exhibit)
|
||||||
|
mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({
|
||||||
|
...source,
|
||||||
|
audioLanguage: lang
|
||||||
|
}))
|
||||||
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
|
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
|
||||||
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
||||||
playable: true,
|
playable: true,
|
||||||
@@ -191,7 +197,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
|
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('深链语言不受支持时回退至中文并规范化地址', async () => {
|
it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
|
||||||
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
|
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
|
||||||
...exhibit,
|
...exhibit,
|
||||||
audioLanguage: request.lang,
|
audioLanguage: request.lang,
|
||||||
@@ -205,8 +211,9 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' }))
|
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
|
||||||
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(2, expect.objectContaining({ lang: 'zh-CN' }))
|
expect(mocks.enterExplainDetail).toHaveBeenCalledWith(expect.objectContaining({ lang: 'en-US' }))
|
||||||
|
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'zh-CN')
|
||||||
expect(wrapper.get('.language-option').classes()).toContain('active')
|
expect(wrapper.get('.language-option').classes()).toContain('active')
|
||||||
expect(wrapper.get('.language-option-text').text()).toBe('中文')
|
expect(wrapper.get('.language-option-text').text()).toBe('中文')
|
||||||
expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN'))
|
expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN'))
|
||||||
@@ -244,6 +251,34 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
|
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('切换语言时使用初次详情响应中的变体,不重复请求详情或正文', async () => {
|
||||||
|
const localizedExhibit = {
|
||||||
|
...exhibit,
|
||||||
|
guideText: '粤语通道使用普通话文案。',
|
||||||
|
audioUrl: '/assets/astrolabe-yue.mp3',
|
||||||
|
audioDuration: 83,
|
||||||
|
audioLanguage: 'yue-HK',
|
||||||
|
audioStatus: 'READY',
|
||||||
|
audioHasText: true,
|
||||||
|
supportedLanguages: ['zh-CN', 'yue-HK', 'en-US']
|
||||||
|
}
|
||||||
|
mocks.selectExplainDetailLanguage.mockReturnValue(localizedExhibit)
|
||||||
|
const wrapper = mount(ExhibitDetail, {
|
||||||
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('.language-option').find((node) => node.text() === '粤语')?.trigger('tap')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.loadExplainDetailText).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK')
|
||||||
|
expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案')
|
||||||
|
expect(wrapper.get('.detail-audio-subtitle').text()).toContain('粤语')
|
||||||
|
})
|
||||||
|
|
||||||
it('无音频时禁用播放按钮并显示原因', async () => {
|
it('无音频时禁用播放按钮并显示原因', async () => {
|
||||||
mocks.enterExplainDetail.mockResolvedValue({
|
mocks.enterExplainDetail.mockResolvedValue({
|
||||||
...exhibit,
|
...exhibit,
|
||||||
|
|||||||
@@ -98,18 +98,21 @@ describe('讲解对象列表返回', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the selected hall context when an object payload omits hall fields', async () => {
|
it('uses the decoded hall name as the page title and detail context', async () => {
|
||||||
const wrapper = mountGuideStopList()
|
const wrapper = mountGuideStopList()
|
||||||
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '恐龙厅' })
|
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '%25E6%25BC%2594%25E5%258C%2596%25E5%258E%2585' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(uni.setNavigationBarTitle).toHaveBeenCalledWith({ title: '演化厅' })
|
||||||
|
expect(document.title).toBe('演化厅')
|
||||||
|
|
||||||
await wrapper.get('[data-testid="detail-click"]').trigger('click')
|
await wrapper.get('[data-testid="detail-click"]').trigger('click')
|
||||||
|
|
||||||
const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url || ''
|
const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url || ''
|
||||||
const params = new URLSearchParams(url.split('?')[1])
|
const params = new URLSearchParams(url.split('?')[1])
|
||||||
expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
|
expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
|
||||||
expect(params.get('hallId')).toBe('hall-1')
|
expect(params.get('hallId')).toBe('hall-1')
|
||||||
expect(params.get('hallName')).toBe('恐龙厅')
|
expect(params.get('hallName')).toBe('演化厅')
|
||||||
expect(params.get('targetType')).toBe('STOP')
|
expect(params.get('targetType')).toBe('STOP')
|
||||||
expect(params.get('targetId')).toBe('target-1')
|
expect(params.get('targetId')).toBe('target-1')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo =
|
|||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
hasText: false,
|
hasText: false,
|
||||||
supportedLanguages: [],
|
supportedLanguages: [],
|
||||||
|
languageVariants: {},
|
||||||
audioStatus: 'MISSING',
|
audioStatus: 'MISSING',
|
||||||
...overrides
|
...overrides
|
||||||
})
|
})
|
||||||
@@ -99,4 +100,60 @@ describe('ExplainUseCase stop image policy', () => {
|
|||||||
expect(detail.image).toBe('/guide-stop.jpg')
|
expect(detail.image).toBe('/guide-stop.jpg')
|
||||||
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
|
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses stop-info language variants locally without requesting play-info or text-info', async () => {
|
||||||
|
const audio = {
|
||||||
|
getStopInfo: vi.fn().mockResolvedValue(createStopInfo({
|
||||||
|
hasAudio: true,
|
||||||
|
hasText: true,
|
||||||
|
supportedLanguages: ['zh-CN', 'yue-HK'],
|
||||||
|
languageVariants: {
|
||||||
|
'zh-CN': {
|
||||||
|
lang: 'zh-CN',
|
||||||
|
enabled: true,
|
||||||
|
playable: true,
|
||||||
|
playUrl: '/audio/zh.mp3',
|
||||||
|
duration: 50,
|
||||||
|
hasText: true,
|
||||||
|
textAvailable: true,
|
||||||
|
text: '普通话讲解词',
|
||||||
|
fallback: false,
|
||||||
|
audioStatus: 'READY'
|
||||||
|
},
|
||||||
|
'yue-HK': {
|
||||||
|
lang: 'yue-HK',
|
||||||
|
enabled: true,
|
||||||
|
playable: true,
|
||||||
|
playUrl: '/audio/yue.mp3',
|
||||||
|
duration: 52,
|
||||||
|
hasText: true,
|
||||||
|
textAvailable: true,
|
||||||
|
text: '粤语通道使用普通话文案',
|
||||||
|
fallback: false,
|
||||||
|
audioStatus: 'READY'
|
||||||
|
}
|
||||||
|
} as GuideStopInfo['languageVariants']
|
||||||
|
})),
|
||||||
|
getPlayInfo: vi.fn(),
|
||||||
|
getTextInfo: vi.fn()
|
||||||
|
} as unknown as AudioPlayInfoRepository
|
||||||
|
const explain = {
|
||||||
|
getExhibitById: vi.fn(),
|
||||||
|
listExplainExhibits: vi.fn()
|
||||||
|
} as unknown as ExplainRepository
|
||||||
|
const useCase = new ExplainUseCase(explain, audio)
|
||||||
|
|
||||||
|
const detail = await useCase.enterExplainDetail({
|
||||||
|
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN'
|
||||||
|
})
|
||||||
|
const cantonese = useCase.selectExplainDetailLanguage(detail, 'yue-HK')
|
||||||
|
|
||||||
|
expect(detail.guideText).toBe('普通话讲解词')
|
||||||
|
expect(detail.audioUrl).toBe('/audio/zh.mp3')
|
||||||
|
expect(cantonese.guideText).toBe('粤语通道使用普通话文案')
|
||||||
|
expect(cantonese.audioUrl).toBe('/audio/yue.mp3')
|
||||||
|
expect(cantonese.audioDuration).toBe(52)
|
||||||
|
expect((audio as any).getPlayInfo).not.toHaveBeenCalled()
|
||||||
|
expect((audio as any).getTextInfo).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,7 +35,36 @@ describe('全局讲解播放器语言切换', () => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => {
|
it('详情页提供语言变体播放地址时直接切换,不请求 play-info', async () => {
|
||||||
|
await player.play({
|
||||||
|
id: 'mandarin-audio',
|
||||||
|
name: '测试讲解',
|
||||||
|
audioUrl: '/museum-assets/audio/mandarin.mp3',
|
||||||
|
language: 'zh-CN'
|
||||||
|
}, {
|
||||||
|
source: {
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: '1823450596800289',
|
||||||
|
lang: 'zh-CN'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(player.switchLanguage('yue-HK', {
|
||||||
|
id: 'cantonese-audio',
|
||||||
|
name: '测试讲解',
|
||||||
|
audioUrl: '/museum-assets/audio/cantonese.mp3',
|
||||||
|
language: 'yue-HK'
|
||||||
|
})).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(repositoryMocks.getPlayInfo).not.toHaveBeenCalled()
|
||||||
|
expect(player.currentSource.value?.lang).toBe('yue-HK')
|
||||||
|
expect(player.currentAudio.value).toEqual(expect.objectContaining({
|
||||||
|
language: 'yue-HK',
|
||||||
|
audioUrl: '/museum-assets/audio/cantonese.mp3'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未提供详情语言变体时保留 play-info 刷新回退', async () => {
|
||||||
repositoryMocks.getPlayInfo.mockResolvedValue({
|
repositoryMocks.getPlayInfo.mockResolvedValue({
|
||||||
playable: true,
|
playable: true,
|
||||||
targetType: 'STOP',
|
targetType: 'STOP',
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
} from '@/domain/poiCategories'
|
} from '@/domain/poiCategories'
|
||||||
import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider'
|
import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider'
|
||||||
import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider'
|
import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider'
|
||||||
import { SgsSdkGuideRepository, StaticGuideRepository } from '@/repositories/GuideRepository'
|
import {
|
||||||
|
canonicalizeVisitorSearchPois,
|
||||||
|
SgsSdkGuideRepository,
|
||||||
|
StaticGuideRepository
|
||||||
|
} from '@/repositories/GuideRepository'
|
||||||
import {
|
import {
|
||||||
SgsSdkGuideModelRepository,
|
SgsSdkGuideModelRepository,
|
||||||
StaticGuideModelRepository
|
StaticGuideModelRepository
|
||||||
@@ -40,7 +44,17 @@ const floorSpaces = [
|
|||||||
{ id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } },
|
{ id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } },
|
||||||
{ id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } },
|
{ id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } },
|
||||||
{ id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } },
|
{ id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } },
|
||||||
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } }
|
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } },
|
||||||
|
{ id: 'space-private-tea', name: '茶水间', type: 'service_space', floorId: 'L1', center: { x: 5, y: 12, z: 5 } },
|
||||||
|
{ id: 'space-private-vip', name: '贵宾接待区', type: 'service_space', floorId: 'L1', center: { x: 6, y: 12, z: 6 } },
|
||||||
|
{
|
||||||
|
id: 'space-public-rest',
|
||||||
|
name: '游客休息区',
|
||||||
|
type: 'service_space',
|
||||||
|
floorId: 'L1',
|
||||||
|
center: { x: 7, y: 12, z: 7 },
|
||||||
|
visitorVisible: true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const floorPois = [
|
const floorPois = [
|
||||||
@@ -120,17 +134,25 @@ describe('GuideRepository visitor search contracts', () => {
|
|||||||
const provider = createSgsProvider()
|
const provider = createSgsProvider()
|
||||||
const repository = new SgsSdkGuideRepository(provider)
|
const repository = new SgsSdkGuideRepository(provider)
|
||||||
|
|
||||||
const [defaultResults, availability] = await Promise.all([
|
const [defaultResults, availability, privateKeyword, publicKeyword] = await Promise.all([
|
||||||
repository.listDestinationPois('L1'),
|
repository.listDestinationPois('L1'),
|
||||||
repository.getQuickFindCategoryAvailability('L1')
|
repository.getQuickFindCategoryAvailability('L1'),
|
||||||
|
repository.searchPois('茶水间', 'L1'),
|
||||||
|
repository.searchPois('游客休息区', 'L1')
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([
|
expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([
|
||||||
'hall-space-hall',
|
'hall-space-hall',
|
||||||
'hall-space-cinema',
|
'hall-space-cinema',
|
||||||
'space-space-dining',
|
'space-space-dining',
|
||||||
'space-space-shopping'
|
'space-space-shopping',
|
||||||
|
'space-space-public-rest'
|
||||||
]))
|
]))
|
||||||
|
for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) {
|
||||||
|
expect(defaultResults.map((poi) => poi.id)).not.toContain(privatePoiId)
|
||||||
|
}
|
||||||
|
expect(privateKeyword).toEqual([])
|
||||||
|
expect(publicKeyword.map((poi) => poi.id)).toEqual(['space-space-public-rest'])
|
||||||
expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([
|
expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([
|
||||||
'restroom',
|
'restroom',
|
||||||
'elevator',
|
'elevator',
|
||||||
@@ -160,9 +182,13 @@ describe('GuideRepository visitor search contracts', () => {
|
|||||||
// survive model marker deduplication.
|
// survive model marker deduplication.
|
||||||
expect(renderPoiIds.has('restroom')).toBe(true)
|
expect(renderPoiIds.has('restroom')).toBe(true)
|
||||||
expect(renderPoiIds.has('elevator')).toBe(true)
|
expect(renderPoiIds.has('elevator')).toBe(true)
|
||||||
|
expect(renderPoiIds.has('space-space-public-rest')).toBe(true)
|
||||||
for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) {
|
for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) {
|
||||||
expect(renderPoiIds.has(hiddenPoiId)).toBe(false)
|
expect(renderPoiIds.has(hiddenPoiId)).toBe(false)
|
||||||
}
|
}
|
||||||
|
for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) {
|
||||||
|
expect(renderPoiIds.has(privatePoiId)).toBe(false)
|
||||||
|
}
|
||||||
const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => (
|
const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => (
|
||||||
repository.listQuickFindPois(categoryId, 'L1')
|
repository.listQuickFindPois(categoryId, 'L1')
|
||||||
)))).flat().map((poi) => poi.id)
|
)))).flat().map((poi) => poi.id)
|
||||||
@@ -188,6 +214,115 @@ describe('GuideRepository visitor search contracts', () => {
|
|||||||
expect(hiddenKeyword).toEqual([])
|
expect(hiddenKeyword).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not resolve private direct IDs outside the visitor search pool', async () => {
|
||||||
|
const repository = new SgsSdkGuideRepository(createSgsProvider({
|
||||||
|
queryPois: vi.fn().mockResolvedValue([{
|
||||||
|
id: 'private-direct-space',
|
||||||
|
name: '茶水间',
|
||||||
|
type: 'service_space',
|
||||||
|
floorId: 'L1',
|
||||||
|
visitorVisible: false,
|
||||||
|
position: { x: 30, y: 12, z: 30 }
|
||||||
|
}])
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(repository.getPoiById('private-direct-space')).resolves.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps private and structural space duplicates out of search lists and map markers', async () => {
|
||||||
|
const provider = createSgsProvider({
|
||||||
|
getFloorPois: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'poi-private-dining',
|
||||||
|
name: '西侧餐饮区',
|
||||||
|
type: 'restaurant',
|
||||||
|
floorId: 'L1',
|
||||||
|
spatialAreaId: 'space-private-dining',
|
||||||
|
position: { x: 30, y: 12, z: 30 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'poi-service-space',
|
||||||
|
name: '服务空间',
|
||||||
|
type: 'service_space',
|
||||||
|
floorId: 'L1',
|
||||||
|
position: { x: 31, y: 12, z: 31 }
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
|
||||||
|
getFloorSpaces: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'space-private-dining',
|
||||||
|
name: '西侧餐饮区',
|
||||||
|
type: 'restaurant',
|
||||||
|
floorId: 'L1',
|
||||||
|
visitorVisible: false,
|
||||||
|
center: { x: 30, y: 12, z: 30 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'space-private-cinema',
|
||||||
|
name: '私享影院',
|
||||||
|
type: 'theater',
|
||||||
|
floorId: 'L1',
|
||||||
|
visitorVisible: false,
|
||||||
|
center: { x: 32, y: 12, z: 32 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'space-route-node',
|
||||||
|
name: '导航区域',
|
||||||
|
type: 'navigable_place',
|
||||||
|
floorId: 'L1',
|
||||||
|
center: { x: 33, y: 12, z: 33 }
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
getNavigablePlaces: vi.fn().mockResolvedValue([])
|
||||||
|
})
|
||||||
|
const repository = new SgsSdkGuideRepository(provider)
|
||||||
|
const modelRepository = new SgsSdkGuideModelRepository(provider, repository)
|
||||||
|
|
||||||
|
const [defaultResults, dining, cinema, privateKeyword, serviceKeyword, structuralKeyword, renderPois] = await Promise.all([
|
||||||
|
repository.listDestinationPois('L1'),
|
||||||
|
repository.listQuickFindPois('dining', 'L1'),
|
||||||
|
repository.listQuickFindPois('cinema', 'L1'),
|
||||||
|
repository.searchPois('西侧餐饮区', 'L1'),
|
||||||
|
repository.searchPois('服务空间', 'L1'),
|
||||||
|
repository.searchPois('导航区域', 'L1'),
|
||||||
|
modelRepository.loadFloorPois('L1')
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(defaultResults).toEqual([])
|
||||||
|
expect(dining).toEqual([])
|
||||||
|
expect(cinema).toEqual([])
|
||||||
|
expect(privateKeyword).toEqual([])
|
||||||
|
expect(serviceKeyword).toEqual([])
|
||||||
|
expect(structuralKeyword).toEqual([])
|
||||||
|
expect(renderPois).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges source-place duplicates before returning canonical visitor IDs', () => {
|
||||||
|
const basePoi = {
|
||||||
|
floorId: 'L1',
|
||||||
|
floorLabel: '1F',
|
||||||
|
primaryCategory: { id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' },
|
||||||
|
categories: [{ id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }],
|
||||||
|
positionGltf: [40, 12, 40] as [number, number, number],
|
||||||
|
accessible: false,
|
||||||
|
kind: 'facility' as const,
|
||||||
|
sourcePlaceId: 'place-cafe'
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = canonicalizeVisitorSearchPois([
|
||||||
|
{ ...basePoi, id: 'poi-cafe-a', name: '中庭咖啡' },
|
||||||
|
{
|
||||||
|
...basePoi,
|
||||||
|
id: 'poi-cafe-b',
|
||||||
|
name: '中庭咖啡取餐台',
|
||||||
|
positionGltf: [41, 12, 41]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(results.map((poi) => poi.id)).toEqual(['poi-cafe-a'])
|
||||||
|
})
|
||||||
|
|
||||||
it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => {
|
it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => {
|
||||||
const repository = new SgsSdkGuideRepository(createSgsProvider({
|
const repository = new SgsSdkGuideRepository(createSgsProvider({
|
||||||
getFloorPois: vi.fn().mockResolvedValue([{
|
getFloorPois: vi.fn().mockResolvedValue([{
|
||||||
@@ -348,6 +483,24 @@ describe('GuideRepository visitor search contracts', () => {
|
|||||||
primaryCategoryZh: '基础服务设施',
|
primaryCategoryZh: '基础服务设施',
|
||||||
iconType: 'restroom',
|
iconType: 'restroom',
|
||||||
positionGltf: position(4)
|
positionGltf: position(4)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'static-vip-reception',
|
||||||
|
name: '贵宾接待区',
|
||||||
|
floorId: 'L1',
|
||||||
|
primaryCategory: 'basic_service_facility',
|
||||||
|
primaryCategoryZh: '基础服务设施',
|
||||||
|
iconType: 'restroom',
|
||||||
|
positionGltf: position(5)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'static-vip-restroom',
|
||||||
|
name: '贵宾卫生间',
|
||||||
|
floorId: 'L1',
|
||||||
|
primaryCategory: 'basic_service_facility',
|
||||||
|
primaryCategoryZh: '基础服务设施',
|
||||||
|
iconType: 'restroom',
|
||||||
|
positionGltf: position(6)
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
} as unknown as StaticNavAssetsProvider
|
} as unknown as StaticNavAssetsProvider
|
||||||
@@ -362,6 +515,10 @@ describe('GuideRepository visitor search contracts', () => {
|
|||||||
await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([
|
await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([
|
||||||
{ id: 'static-restroom' }
|
{ id: 'static-restroom' }
|
||||||
])
|
])
|
||||||
|
await expect(repository.searchPois('茶水间', 'L1')).resolves.toEqual([])
|
||||||
|
await expect(repository.searchPois('贵宾', 'L1')).resolves.toEqual([])
|
||||||
|
await expect(repository.getPoiById('static-tea-room')).resolves.toBeNull()
|
||||||
|
await expect(repository.getPoiById('static-vip-restroom')).resolves.toBeNull()
|
||||||
await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([
|
await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([
|
||||||
expect.objectContaining({ id: 'static-service-desk' })
|
expect.objectContaining({ id: 'static-service-desk' })
|
||||||
])
|
])
|
||||||
@@ -397,6 +554,24 @@ describe('GuideRepository visitor search contracts', () => {
|
|||||||
primaryCategoryZh: '讲解点',
|
primaryCategoryZh: '讲解点',
|
||||||
iconType: 'guide',
|
iconType: 'guide',
|
||||||
positionGltf: position(2)
|
positionGltf: position(2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'static-private-tea',
|
||||||
|
name: '茶水间',
|
||||||
|
floorId: 'L1',
|
||||||
|
primaryCategory: 'basic_service_facility',
|
||||||
|
primaryCategoryZh: '基础服务设施',
|
||||||
|
iconType: 'restroom',
|
||||||
|
positionGltf: position(3)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'static-vip-restroom',
|
||||||
|
name: '贵宾卫生间',
|
||||||
|
floorId: 'L1',
|
||||||
|
primaryCategory: 'basic_service_facility',
|
||||||
|
primaryCategoryZh: '基础服务设施',
|
||||||
|
iconType: 'restroom',
|
||||||
|
positionGltf: position(4)
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
} as unknown as StaticNavAssetsProvider
|
} as unknown as StaticNavAssetsProvider
|
||||||
|
|||||||
78
tests/unit/GuideStopInfoAdapter.spec.ts
Normal file
78
tests/unit/GuideStopInfoAdapter.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
|
||||||
|
|
||||||
|
describe('guide stop-info adapter', () => {
|
||||||
|
it('normalizes aggregated language variants and exposes only displayable languages', () => {
|
||||||
|
const result = toGuideStopInfo({
|
||||||
|
available: true,
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: '9001',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
imageStatus: 'READY',
|
||||||
|
coverImageUrl: '/museum-assets/cover.webp',
|
||||||
|
languageVariants: [
|
||||||
|
{
|
||||||
|
lang: 'zh',
|
||||||
|
enabled: true,
|
||||||
|
playable: true,
|
||||||
|
audioStatus: 'READY',
|
||||||
|
playUrl: '/museum-assets/audio/zh.mp3',
|
||||||
|
duration: '50',
|
||||||
|
audioId: 12,
|
||||||
|
hasText: true,
|
||||||
|
textAvailable: true,
|
||||||
|
text: '普通话讲解词',
|
||||||
|
textLength: '6',
|
||||||
|
textHash: 'zh-hash'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: 'yue-HK',
|
||||||
|
enabled: true,
|
||||||
|
playable: false,
|
||||||
|
audioStatus: 'MISSING',
|
||||||
|
textAvailable: true,
|
||||||
|
text: '粤语使用普通话文案',
|
||||||
|
reason: 'NO_PUBLISHED_AUDIO'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: 'en-US',
|
||||||
|
enabled: false,
|
||||||
|
playable: true,
|
||||||
|
playUrl: '/museum-assets/audio/en.mp3'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: '9001',
|
||||||
|
lang: 'zh-CN'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.supportedLanguages).toEqual(['zh-CN', 'yue-HK'])
|
||||||
|
expect(result.languageVariants['zh-CN']).toMatchObject({
|
||||||
|
playable: true,
|
||||||
|
playUrl: '/museum-assets/audio/zh.mp3',
|
||||||
|
duration: 50,
|
||||||
|
audioId: '12',
|
||||||
|
text: '普通话讲解词',
|
||||||
|
textLength: 6
|
||||||
|
})
|
||||||
|
expect(result.languageVariants['yue-HK']).toMatchObject({
|
||||||
|
playable: false,
|
||||||
|
textAvailable: true,
|
||||||
|
reason: 'NO_PUBLISHED_AUDIO'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps supportedLanguages as the compatibility fallback when variants are absent', () => {
|
||||||
|
const result = toGuideStopInfo({
|
||||||
|
supportedLanguages: ['zh-CN', 'en-US']
|
||||||
|
}, {
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: '9001',
|
||||||
|
lang: 'zh-CN'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
|
||||||
|
expect(result.languageVariants).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,6 +18,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
searchRestoreCount: 0,
|
searchRestoreCount: 0,
|
||||||
searchCollapseAfterDetailCount: 0,
|
searchCollapseAfterDetailCount: 0,
|
||||||
searchResetCount: 0,
|
searchResetCount: 0,
|
||||||
|
floorSwitchRequests: [] as string[],
|
||||||
onShowHandler: null as null | (() => Promise<void> | void),
|
onShowHandler: null as null | (() => Promise<void> | void),
|
||||||
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
|
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
|
||||||
hallResolutionPromises: [] as Array<Promise<Array<{
|
hallResolutionPromises: [] as Array<Promise<Array<{
|
||||||
@@ -102,7 +103,7 @@ const GuideMapShellStub = defineComponent({
|
|||||||
visiblePoiIds: { type: Array, default: null },
|
visiblePoiIds: { type: Array, default: null },
|
||||||
targetFocusRequest: { type: Object, default: null }
|
targetFocusRequest: { type: Object, default: null }
|
||||||
},
|
},
|
||||||
emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change'],
|
emits: ['floor-change', 'floor-request', 'floor-switch-failed', 'initial-model-ready', 'poi-click', 'selection-clear', 'indoor-view-change'],
|
||||||
setup() {
|
setup() {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
testState.mapMountCount += 1
|
testState.mapMountCount += 1
|
||||||
@@ -113,6 +114,10 @@ const GuideMapShellStub = defineComponent({
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
switchFloor(floorId: string) {
|
||||||
|
testState.floorSwitchRequests.push(floorId)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
resetToViewBaseline(options: {
|
resetToViewBaseline(options: {
|
||||||
view: 'overview' | 'floor'
|
view: 'overview' | 'floor'
|
||||||
floorId?: string
|
floorId?: string
|
||||||
@@ -217,6 +222,7 @@ beforeEach(() => {
|
|||||||
testState.searchRestoreCount = 0
|
testState.searchRestoreCount = 0
|
||||||
testState.searchCollapseAfterDetailCount = 0
|
testState.searchCollapseAfterDetailCount = 0
|
||||||
testState.searchResetCount = 0
|
testState.searchResetCount = 0
|
||||||
|
testState.floorSwitchRequests = []
|
||||||
testState.onShowHandler = null
|
testState.onShowHandler = null
|
||||||
testState.halls = []
|
testState.halls = []
|
||||||
testState.hallResolutionPromises = []
|
testState.hallResolutionPromises = []
|
||||||
@@ -355,7 +361,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
|
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => {
|
it('等待渲染器提交目标楼层后,才将快捷分类结果 ID 传给地图', async () => {
|
||||||
const wrapper = await mountIndex()
|
const wrapper = await mountIndex()
|
||||||
const search = wrapper.getComponent(PoiSearchPanelStub)
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
const map = wrapper.getComponent(GuideMapShellStub)
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
@@ -370,6 +376,13 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
})
|
})
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(testState.floorSwitchRequests).toEqual(['L2'])
|
||||||
|
expect(map.props('activeFloor')).toBe('L1')
|
||||||
|
expect(map.props('visiblePoiIds')).toEqual([])
|
||||||
|
|
||||||
|
map.vm.$emit('floor-change', 'L2')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(map.props('activeFloor')).toBe('L2')
|
expect(map.props('activeFloor')).toBe('L2')
|
||||||
expect(map.props('indoorView')).toBe('floor')
|
expect(map.props('indoorView')).toBe('floor')
|
||||||
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
||||||
@@ -388,6 +401,78 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(map.props('visiblePoiIds')).toEqual([])
|
expect(map.props('visiblePoiIds')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('三维模型重新以总览就绪后,不将旧单层状态误作楼层提交', async () => {
|
||||||
|
const wrapper = await mountIndex()
|
||||||
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
|
|
||||||
|
map.vm.$emit('floor-change', 'L1')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(map.props('indoorView')).toBe('floor')
|
||||||
|
|
||||||
|
map.vm.$emit('initial-model-ready', { view: 'overview', floorId: 'L1' })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(map.props('indoorView')).toBe('overview')
|
||||||
|
|
||||||
|
search.vm.$emit('category-mode-change', true)
|
||||||
|
search.vm.$emit('results-change', {
|
||||||
|
...categoryContext,
|
||||||
|
floorId: 'L1',
|
||||||
|
floorLabel: '1F',
|
||||||
|
active: true,
|
||||||
|
requestId: 20
|
||||||
|
})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(testState.floorSwitchRequests).toEqual(['L1'])
|
||||||
|
expect(map.props('visiblePoiIds')).toEqual([])
|
||||||
|
|
||||||
|
map.vm.$emit('floor-change', 'L1')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('忽略过期搜索结果,不允许旧楼层覆盖当前待提交地图状态', async () => {
|
||||||
|
const wrapper = await mountIndex()
|
||||||
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
|
|
||||||
|
search.vm.$emit('category-mode-change', true)
|
||||||
|
search.vm.$emit('results-change', { ...categoryContext, active: true, requestId: 8 })
|
||||||
|
search.vm.$emit('results-change', {
|
||||||
|
...categoryContext,
|
||||||
|
floorId: 'L1',
|
||||||
|
floorLabel: '1F',
|
||||||
|
visiblePoiIds: ['stale-l1'],
|
||||||
|
active: true,
|
||||||
|
requestId: 7
|
||||||
|
})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(testState.floorSwitchRequests).toEqual(['L2'])
|
||||||
|
map.vm.$emit('floor-change', 'L2')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('楼层切换失败时清空快捷分类标记,重试提交后恢复与列表一致的 ID', async () => {
|
||||||
|
const wrapper = await mountIndex()
|
||||||
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
|
|
||||||
|
search.vm.$emit('category-mode-change', true)
|
||||||
|
search.vm.$emit('results-change', { ...categoryContext, active: true, requestId: 12 })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
map.vm.$emit('floor-switch-failed', { floorId: 'L2', floorLabel: '2F' })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(map.props('activeFloor')).toBe('L1')
|
||||||
|
expect(map.props('visiblePoiIds')).toEqual([])
|
||||||
|
|
||||||
|
map.vm.$emit('floor-change', 'L2')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
||||||
|
})
|
||||||
|
|
||||||
it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => {
|
it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => {
|
||||||
const originalFloors = [...floors]
|
const originalFloors = [...floors]
|
||||||
floors.splice(0, floors.length,
|
floors.splice(0, floors.length,
|
||||||
@@ -417,6 +502,8 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
...categoryContext,
|
...categoryContext,
|
||||||
active: true
|
active: true
|
||||||
})
|
})
|
||||||
|
map.vm.$emit('floor-change', 'L2')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
search.vm.$emit('result-tap', poi, categoryContext)
|
search.vm.$emit('result-tap', poi, categoryContext)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -486,6 +573,8 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
|
|
||||||
search.vm.$emit('category-mode-change', true)
|
search.vm.$emit('category-mode-change', true)
|
||||||
search.vm.$emit('results-change', { ...categoryContext, active: true })
|
search.vm.$emit('results-change', { ...categoryContext, active: true })
|
||||||
|
map.vm.$emit('floor-change', 'L2')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
search.vm.$emit('result-tap', poi, categoryContext)
|
search.vm.$emit('result-tap', poi, categoryContext)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ const lastResultState = (wrapper: ReturnType<typeof mount>) => (
|
|||||||
categoryId: string
|
categoryId: string
|
||||||
keyword: string
|
keyword: string
|
||||||
pending?: boolean
|
pending?: boolean
|
||||||
|
requestId?: number
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,6 +324,36 @@ describe('PoiSearchPanel 搜索状态契约', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('首页分类结果在组件重挂载后仍发送递增的跨组件版本号', async () => {
|
||||||
|
const firstWrapper = mount(PoiSearchPanel, {
|
||||||
|
props: {
|
||||||
|
variant: 'home',
|
||||||
|
currentFloorId: 'floor-1',
|
||||||
|
currentFloorLabel: '1F'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await flushMountedSearch()
|
||||||
|
await firstWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
|
||||||
|
await flushMountedSearch()
|
||||||
|
const firstRequestId = lastResultState(firstWrapper).requestId
|
||||||
|
firstWrapper.unmount()
|
||||||
|
|
||||||
|
const secondWrapper = mount(PoiSearchPanel, {
|
||||||
|
props: {
|
||||||
|
variant: 'home',
|
||||||
|
currentFloorId: 'floor-1',
|
||||||
|
currentFloorLabel: '1F'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await flushMountedSearch()
|
||||||
|
await secondWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
|
||||||
|
await flushMountedSearch()
|
||||||
|
|
||||||
|
expect(firstRequestId).toEqual(expect.any(Number))
|
||||||
|
expect(lastResultState(secondWrapper).requestId).toBeGreaterThan(firstRequestId as number)
|
||||||
|
secondWrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => {
|
it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => {
|
||||||
const initialState = createSearchState()
|
const initialState = createSearchState()
|
||||||
const deferredCategory = createDeferred<GuidePoiSearchViewState>()
|
const deferredCategory = createDeferred<GuidePoiSearchViewState>()
|
||||||
|
|||||||
86
tests/unit/finalizeH5BuildPolicy.spec.ts
Normal file
86
tests/unit/finalizeH5BuildPolicy.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const {
|
||||||
|
isTextBuildFile,
|
||||||
|
resolveH5BuildPolicy,
|
||||||
|
tencentMapKeyPlaceholder
|
||||||
|
} = require('../../scripts/finalize-h5-build-policy.cjs') as {
|
||||||
|
isTextBuildFile: (filePath: string) => boolean
|
||||||
|
resolveH5BuildPolicy: (options: {
|
||||||
|
args?: string[]
|
||||||
|
projectRoot: string
|
||||||
|
environment?: Record<string, string | undefined>
|
||||||
|
loadProductionEnv?: (root: string) => Record<string, string | undefined>
|
||||||
|
}) => {
|
||||||
|
allowPlaceholder: boolean
|
||||||
|
requireTencentMapKey: boolean
|
||||||
|
tencentMapKey: string
|
||||||
|
}
|
||||||
|
tencentMapKeyPlaceholder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = 'C:/museum-guide'
|
||||||
|
const validTencentMapKey = 'ABCDE-FGHIJ-KLMNO-PQRST-UVWXY-12345'
|
||||||
|
|
||||||
|
describe('finalize H5 build policy', () => {
|
||||||
|
it('keeps test builds isolated from production map credentials', () => {
|
||||||
|
const loadProductionEnv = vi.fn(() => ({
|
||||||
|
VITE_TENCENT_MAP_KEY: validTencentMapKey
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(resolveH5BuildPolicy({
|
||||||
|
args: ['--allow-placeholder'],
|
||||||
|
projectRoot,
|
||||||
|
environment: { VITE_TENCENT_MAP_KEY: validTencentMapKey },
|
||||||
|
loadProductionEnv
|
||||||
|
})).toEqual({
|
||||||
|
allowPlaceholder: true,
|
||||||
|
requireTencentMapKey: false,
|
||||||
|
tencentMapKey: ''
|
||||||
|
})
|
||||||
|
expect(loadProductionEnv).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads a well-formed production key only for the production gate', () => {
|
||||||
|
const loadProductionEnv = vi.fn(() => ({
|
||||||
|
VITE_TENCENT_MAP_KEY: validTencentMapKey
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(resolveH5BuildPolicy({
|
||||||
|
args: ['--require-tencent-map-key'],
|
||||||
|
projectRoot,
|
||||||
|
environment: {},
|
||||||
|
loadProductionEnv
|
||||||
|
})).toEqual({
|
||||||
|
allowPlaceholder: false,
|
||||||
|
requireTencentMapKey: true,
|
||||||
|
tencentMapKey: validTencentMapKey
|
||||||
|
})
|
||||||
|
expect(loadProductionEnv).toHaveBeenCalledWith(projectRoot)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects conflicting flags, placeholders, and malformed production keys', () => {
|
||||||
|
expect(() => resolveH5BuildPolicy({
|
||||||
|
args: ['--allow-placeholder', '--require-tencent-map-key'],
|
||||||
|
projectRoot,
|
||||||
|
environment: {}
|
||||||
|
})).toThrow('cannot require a Tencent map key')
|
||||||
|
|
||||||
|
for (const key of ['', tencentMapKeyPlaceholder, 'fake-key']) {
|
||||||
|
expect(() => resolveH5BuildPolicy({
|
||||||
|
args: ['--require-tencent-map-key'],
|
||||||
|
projectRoot,
|
||||||
|
environment: { VITE_TENCENT_MAP_KEY: key },
|
||||||
|
loadProductionEnv: () => ({})
|
||||||
|
})).toThrow('well-formed non-placeholder')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scans source maps as text build artifacts', () => {
|
||||||
|
expect(isTextBuildFile('assets/index.js.map')).toBe(true)
|
||||||
|
expect(isTextBuildFile('assets/index.js')).toBe(true)
|
||||||
|
expect(isTextBuildFile('assets/model.glb')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter'
|
import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter'
|
||||||
import { resolvePoiCategory } from '@/domain/poiCategories'
|
import { isVisitorSearchPoi, resolvePoiCategory } from '@/domain/poiCategories'
|
||||||
import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider'
|
import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider'
|
||||||
|
|
||||||
const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({
|
const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({
|
||||||
@@ -52,10 +52,23 @@ describe('static nav POI adapter', () => {
|
|||||||
const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom'))
|
const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom'))
|
||||||
const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom'))
|
const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom'))
|
||||||
const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom'))
|
const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom'))
|
||||||
|
const vipReception = toMuseumPoi(createStaticPoi('贵宾接待区', 'restroom'))
|
||||||
|
const vipRestroom = toMuseumPoi(createStaticPoi('贵宾卫生间', 'restroom'))
|
||||||
|
|
||||||
expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center')
|
expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center')
|
||||||
expect(resolvePoiCategory(locker)?.id).toBe('service-center')
|
expect(resolvePoiCategory(locker)?.id).toBe('service-center')
|
||||||
expect(resolvePoiCategory(teaRoom)).toBeNull()
|
expect(resolvePoiCategory(teaRoom)).toBeNull()
|
||||||
|
expect(teaRoom.visitorVisible).toBe(false)
|
||||||
|
expect(vipReception.visitorVisible).toBe(false)
|
||||||
|
expect(vipRestroom.visitorVisible).toBe(false)
|
||||||
|
expect(toMuseumPoi({
|
||||||
|
...createStaticPoi('茶水间', 'restroom'),
|
||||||
|
visitorVisible: true
|
||||||
|
}).visitorVisible).toBe(true)
|
||||||
|
expect(toMuseumPoi({
|
||||||
|
...createStaticPoi('贵宾卫生间', 'restroom'),
|
||||||
|
visitorVisible: true
|
||||||
|
}).visitorVisible).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('splits generic touring fallback records into their visitor-facing semantic kinds', () => {
|
it('splits generic touring fallback records into their visitor-facing semantic kinds', () => {
|
||||||
@@ -73,4 +86,19 @@ describe('static nav POI adapter', () => {
|
|||||||
})
|
})
|
||||||
expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office')
|
expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a hall visible when its secondary category describes an entrance', () => {
|
||||||
|
const hall = toMuseumPoi(createTouringPoi('地球展厅'))
|
||||||
|
hall.kind = 'hall'
|
||||||
|
hall.categories = [
|
||||||
|
hall.primaryCategory,
|
||||||
|
{
|
||||||
|
id: 'exhibition_hall_entrance',
|
||||||
|
label: '展厅出入口',
|
||||||
|
iconType: 'hall_entrance'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(isVisitorSearchPoi(hall)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user