1 Commits

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

Made-with: Proma
2026-08-25 18:01:20 +08:00
20 changed files with 889 additions and 1196 deletions

View File

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

View File

@@ -1,613 +0,0 @@
# 测试服务器导览 H5 部署手册
最后更新2026-09-15
## 1. 部署目标
- 站点:`guide.whaoyue.com`
- 服务器公网 IP`124.220.83.186`
- SSH 用户:`root`
- 操作系统Ubuntu 22.04.5 LTS
- HTTPS 入口:`https://guide.whaoyue.com:4433/`
- HTTP/IP 测试入口:`http://124.220.83.186:8888/`
- 部署对象:`frontend-miniapp` H5 构建产物
- Nginx 容器:`sgs-nature-nginx`
`8888` 直接提供明文 HTTP不再跳转到 HTTPS并接受域名、公网 IP 和其他 Host。HTTP 仅用于测试;请求内容、登录凭据和 Token 均不会被加密,不得作为生产入口。
Nginx 源站也支持 `http://guide.whaoyue.com:8888/`,但截至 2026-09-15腾讯侧会在请求到达 Nginx 前将该域名 HTTP 请求重定向到 DNSPod `webblock` 页面。因此公网 HTTP 验收应使用 IP 入口;该外部拦截不能通过本机 Nginx 配置消除。
域名 DNS A 记录应指向:
```text
guide.whaoyue.com -> 124.220.83.186
```
## 2. 当前部署拓扑
```text
公网用户
-> 124.220.83.186:8888HTTP/ guide.whaoyue.com:4433HTTPS
-> sgs-nature-nginx Docker 容器
-> 容器使用 host 网络
-> /usr/share/nginx/html/guide
-> 宿主机 /data/sgs-nature/web/guide
```
`sgs-nature-nginx` 当前不是 Docker Compose、Docker Stack、systemd 或 1Panel 项目管理的容器,而是手动创建并启动的 Docker 容器。
容器实际参数:
```text
镜像nginx:latest
Nginx1.31.3
网络host
重启策略unless-stopped
启动命令nginx -g 'daemon off;'
```
当前容器挂载:
| 宿主机路径 | 容器路径 | 权限 | 用途 |
| --- | --- | --- | --- |
| `/data/sgs-nature/deploy/nginx.conf` | `/etc/nginx/conf.d/default.conf` | 只读 | Nginx 配置 |
| `/data/sgs-nature/web` | `/usr/share/nginx/html` | 只读 | 静态站点和资源 |
由于容器使用 `host` 网络Docker 没有 `-p` 端口映射。Nginx 配置中的 `listen 8888``listen 4433` 就是宿主机的监听端口。`8888` 直接提供 HTTP`4433` 提供 HTTPS。
不要操作或重启服务器上另一个名为 `nginx` 的容器。该容器占用公网标准端口 `80/443`,与本导览容器职责不同。因此导览 HTTP 入口必须显式携带 `:8888`,不能使用无端口的 `http://124.220.83.186/`
## 3. 服务器目录
导览站点目录:
```text
/data/sgs-nature/web/guide/
```
容器内对应目录:
```text
/usr/share/nginx/html/guide/
```
证书目录按当前部署方案放在已有 Web 挂载目录内:
```text
/data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem
/data/sgs-nature/web/ssl/guide.whaoyue.com/privkey.pem
```
容器内证书路径:
```text
/usr/share/nginx/html/ssl/guide.whaoyue.com/fullchain.pem
/usr/share/nginx/html/ssl/guide.whaoyue.com/privkey.pem
```
证书目录位于 Web 挂载目录下,因此 Nginx 配置必须拒绝公网访问:
```nginx
location ^~ /ssl/ {
deny all;
return 404;
}
```
证书权限建议:
```text
fullchain.pem0644
privkey.pem0600
```
相关部署和备份文件:
```text
/data/sgs-nature/deploy/nginx.conf
/data/sgs-nature/backup/
```
## 4. 本地构建
项目根目录:
```text
E:\MyWork\深圳国际艺术馆\museum-guide\museum-guide-v4.0\frontend-miniapp
```
环境要求:
- Node.js `>=20 <25`
- pnpm 9
安装依赖:
```powershell
pnpm install
```
测试环境默认配置文件为 `.env.test`,关键配置如下:
```dotenv
VITE_APP_PUBLIC_BASE=/
VITE_GUIDE_DATA_SOURCE_MODE=sdk
VITE_EXPLAIN_CONTENT_SOURCE_MODE=remote
VITE_DATA_SOURCE_MODE=sdk
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api
VITE_AUDIO_API_BASE_URL=/app-api
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js?v=2.5.0
VITE_SGS_H5_ENGINE_URL=/engine/index.html
VITE_SGS_SDK_ORIGIN=
```
由于站点通过域名根路径提供服务,`VITE_APP_PUBLIC_BASE` 必须为 `/`,不能设置为 `/guide/``guide` 是服务器上的目录名,不是 URL 路径。
执行质量检查和测试构建:
```powershell
pnpm type-check
pnpm lint
pnpm build:test:h5
```
`pnpm build:test:h5` 会生成 H5 产物,并复制以下资源:
- `static/nav-assets`
- `static/guide-data`
- `static/sgs-map-sdk`
- `static/three`
- `static/icons`
- `static/explain`
- `static/Fonts`
- `engine`
- 讲解图片、占位图片等补充资源
构建目录:
```text
dist/build/h5/
```
生产发布需要真实腾讯地图 Web Key 时,使用生产构建:
```powershell
$env:VITE_TENCENT_MAP_KEY = '<真实腾讯地图 Web Key>'
pnpm build:h5
Remove-Item Env:VITE_TENCENT_MAP_KEY
```
不要将真实 Key、密码、Token 或其他密钥提交到仓库。
构建后建议检查:
```powershell
Test-Path dist\build\h5\index.html
Get-ChildItem dist\build\h5 -Recurse -File | Measure-Object Length -Sum
rg -n -S --glob '!*.map' '1\.92\.206\.90|guide\.whaoyue\.com|http://124\.220\.83\.186:9000' dist\build\h5
```
最后一个命令不应出现旧服务器地址或不应使用的绝对 HTTP 资源地址。
## 5. 打包和上传
在项目根目录执行:
```powershell
New-Item -ItemType Directory -Force -Path '.tmp' | Out-Null
$archive = '.tmp\museum-guide-h5.tar.gz'
if (Test-Path -LiteralPath $archive) {
Remove-Item -LiteralPath $archive -Force
}
tar -C 'dist\build\h5' -czf $archive .
```
SSH 主机别名为:
```text
museum-guide-test
```
该别名位于本机:
```text
C:\Users\Administrator\.ssh\config
```
上传构建包:
```powershell
scp '.tmp\museum-guide-h5.tar.gz' 'museum-guide-test:/tmp/museum-guide-h5.tar.gz'
```
不要把密码写入 SSH 配置、命令脚本或部署文档。
## 6. 证书
当前使用的证书为 `guide.whaoyue.com` 的 Lets Encrypt 证书,记录的有效期为:
```text
Not Before: 2026-06-07 15:33:10 GMT
Not After: 2026-09-05 15:33:09 GMT
```
截至 2026-09-15该证书已经过期。`4433` 仍能建立忽略证书校验的 HTTPS 连接,但浏览器会显示安全警告;应尽快完成证书续期。开放 `8888` HTTP 仅用于测试,不替代证书续期。
查看证书:
```bash
openssl x509 \
-in /data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem \
-noout -subject -issuer -dates
```
证书续期注意事项:
- 新服务器目前没有发现已配置的 `acme.sh``certbot` 续期任务。
- Lets Encrypt HTTP-01 验证固定使用公网 `80`,不会访问 `8888`
- 公网 `80/443` 已被服务器上另一个 `nginx` 容器占用。
- 证书到期前必须配置 DNS-01或者制定临时借用公网 `80` 完成验证的方案。
- 续期后需要把新证书复制到上述目录,并执行 Nginx 配置检查和 reload。
续期后的最低操作:
```bash
docker exec sgs-nature-nginx nginx -t
docker exec sgs-nature-nginx nginx -s reload
openssl x509 \
-in /data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem \
-noout -subject -issuer -dates
```
## 7. Nginx 配置
配置文件:
```text
/data/sgs-nature/deploy/nginx.conf
```
当前已有 `18101` server服务 `/dp/``/gl/``/map/` 及自然馆测试环境 API。新增导览站点时不得删除或覆盖该 server。
导览 HTTP 与 HTTPS 由同一个 server 块提供,以避免两套静态资源和反向代理规则发生漂移。核心配置:
```nginx
server {
# 测试环境明文 HTTP公网 80 仍由另一 nginx 容器占用。
listen 8888 default_server;
listen 4433 ssl;
server_name guide.whaoyue.com 124.220.83.186 _;
root /usr/share/nginx/html/guide;
index index.html;
ssl_certificate /usr/share/nginx/html/ssl/guide.whaoyue.com/fullchain.pem;
ssl_certificate_key /usr/share/nginx/html/ssl/guide.whaoyue.com/privkey.pem;
location ^~ /ssl/ {
deny all;
return 404;
}
location ^~ /static/nav-assets/ {
try_files $uri =404;
}
location ^~ /assets/ {
try_files $uri =404;
}
location ^~ /engine/ {
try_files $uri =404;
}
location ^~ /static/ {
try_files $uri =404;
}
location ^~ /app-api/ {
proxy_pass http://127.0.0.1:48100;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location ^~ /minio/ {
proxy_pass http://127.0.0.1:9000/;
proxy_http_version 1.1;
proxy_set_header Host 127.0.0.1:9000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location ^~ /museum-assets/ {
proxy_pass http://127.0.0.1:9000/museum-assets/;
proxy_http_version 1.1;
proxy_set_header Host 127.0.0.1:9000;
expires 30d;
add_header Cache-Control "public";
proxy_read_timeout 300s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
`/static/nav-assets/` 必须使用 `try_files $uri =404`,避免缺失 JSON、GLB 或其他模型资源时错误返回 SPA 的 `index.html`
修改配置前备份:
```bash
TS=$(date +%Y%m%d%H%M%S)
cp -p /data/sgs-nature/deploy/nginx.conf \
"/data/sgs-nature/backup/nginx-before-guide-$TS.conf"
```
配置文件以单文件 bind mount 方式挂载。修改时必须保留宿主机文件 inode例如原地编辑或使用 `cat candidate.conf > "$CONFIG"`;不要使用 `mv``install` 或其他原子替换方式覆盖路径。替换 inode 后,容器会继续看到旧文件,普通 reload 不会载入新配置。
修改后先确认宿主机和容器内文件一致,再检查并 reload
```bash
CONFIG=/data/sgs-nature/deploy/nginx.conf
sha256sum "$CONFIG"
docker exec sgs-nature-nginx \
sha256sum /etc/nginx/conf.d/default.conf
docker exec sgs-nature-nginx nginx -t
docker exec sgs-nature-nginx nginx -s reload
```
配置检查失败时不得 reload。应使用备份原地恢复配置重新执行 `nginx -t`,确认通过后再 reload。如果已经替换了 inode应先用独立临时容器对宿主机新配置执行 `nginx -t`,然后只重启 `sgs-nature-nginx` 使其重新建立挂载;仍然不要操作另一个名为 `nginx` 的容器。
本部署方式不需要:
- 新增 Docker Compose 文件
- 新增 Nginx systemd 服务
- 新增 Docker 端口映射
- 重启服务器原有 `nginx` 容器
## 8. 发布站点文件
上传压缩包后,在服务器上执行。发布前先备份当前 `guide` 目录:
```bash
set -e
SITE=/data/sgs-nature/web/guide
ARCHIVE=/tmp/museum-guide-h5.tar.gz
TS=$(date +%Y%m%d%H%M%S)
BACKUP=/data/sgs-nature/backup
mkdir -p "$SITE" "$BACKUP"
if [ -f "$SITE/index.html" ]; then
tar -C "$SITE" -czf "$BACKUP/guide-before-deploy-$TS.tar.gz" .
fi
test -f "$ARCHIVE"
find "$SITE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
tar -C "$SITE" -xzf "$ARCHIVE"
mkdir -p /data/sgs-nature/web/ssl/guide.whaoyue.com
printf 'backup=%s\n' "$BACKUP/guide-before-deploy-$TS.tar.gz"
printf 'files=%s\n' "$(find "$SITE" -type f | wc -l)"
```
证书目录不属于 H5 构建包,不能在清理站点目录时误删。证书位于同级的 `/data/sgs-nature/web/ssl/`,不在 `/data/sgs-nature/web/guide/` 内。
如果证书需要更新:
```bash
install -o root -g root -m 0644 fullchain.pem \
/data/sgs-nature/web/ssl/guide.whaoyue.com/fullchain.pem
install -o root -g root -m 0600 privkey.pem \
/data/sgs-nature/web/ssl/guide.whaoyue.com/privkey.pem
```
## 9. 部署后验证
### 9.1 DNS 和端口
在 Windows PowerShell 中:
```powershell
Resolve-DnsName guide.whaoyue.com -Type A
Test-NetConnection 124.220.83.186 -Port 8888
Test-NetConnection guide.whaoyue.com -Port 4433
```
DNS A 记录应为 `124.220.83.186`
### 9.2 HTTP/IP 和 HTTPS
```powershell
curl.exe -I --max-time 30 http://124.220.83.186:8888/
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/
```
期望:
```text
124.220.83.186:8888200 OK不跳转
guide.whaoyue.com:4433200 OK
```
公网直接访问 `http://guide.whaoyue.com:8888/` 可能收到腾讯 DNSPod `webblock``302`,这不代表源站 Nginx 未启用 HTTP。可在服务器本机携带域名 Host 验证源站:
```bash
curl -I -H 'Host: guide.whaoyue.com' http://127.0.0.1:8888/
```
源站期望返回 `200 OK`
检查证书:
```powershell
cmd /c "echo.| openssl s_client -connect guide.whaoyue.com:4433 -servername guide.whaoyue.com 2>NUL | openssl x509 -noout -subject -issuer -dates"
```
### 9.3 关键静态资源
不要固定使用旧版本的导航资源目录名。先从构建产物或服务器目录查找当前 Manifest
```bash
find /data/sgs-nature/web/guide/static/nav-assets \
-name app_nav_manifest.json -print
```
假设当前资源包目录为:
```text
/static/nav-assets/<当前资源包目录>/
```
验证 Manifest、楼层数据和 GLB
```powershell
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/static/guide-data/manifest.json
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/engine/index.html
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/static/icons/marker-exhibit.svg
```
导航资源应至少验证:
- `app_nav_manifest.json``200`JSON
- 一个实际楼层 JSON`200`JSON
- 一个实际 GLB`200``model/gltf-binary`
- 不存在的 GLB`404`
缺失的导航资源不能返回 `index.html`
### 9.4 API 和原有服务
```powershell
curl.exe -I --max-time 30 http://124.220.83.186:8888/app-api/gis/sdk/maps/1/manifest
curl.exe -k -I --max-time 30 https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl.exe -I --max-time 30 http://124.220.83.186:18101/dp/
```
期望:
```text
HTTP 与 HTTPS /app-api/gis/sdk/maps/1/manifest200 application/json
18101 /dp/200
```
### 9.5 私钥保护
```powershell
curl.exe -sS -o NUL -w "%{http_code}\n" `
http://124.220.83.186:8888/ssl/guide.whaoyue.com/privkey.pem
curl.exe -k -sS -o NUL -w "%{http_code}\n" `
https://guide.whaoyue.com:4433/ssl/guide.whaoyue.com/privkey.pem
```
两个入口都应返回:
```text
404
```
### 9.6 浏览器验收
分别打开:
```text
http://124.220.83.186:8888/
https://guide.whaoyue.com:4433/
```
证书续期前,第二个地址会显示证书过期警告。
至少检查:
- 首页正常加载
- 馆外 2D 地图和馆内 3D 入口显示正常
- 进入馆内 3D 后,实际楼层模型可以加载
- 楼层切换和 POI 数据正常
- 讲解列表、展厅、讲解点和详情页正常
- 图片、模型、音频没有 Mixed Content
- 浏览器控制台没有关键资源 404
- 不存在的模型资源不会返回 HTML
- `/dp/``/gl/``/map/` 原有服务未受影响
## 10. 回滚
### 10.1 回滚前端文件
部署时会在以下目录生成备份:
```text
/data/sgs-nature/backup/guide-before-deploy-YYYYMMDDHHMMSS.tar.gz
```
回滚:
```bash
set -e
SITE=/data/sgs-nature/web/guide
BACKUP=/data/sgs-nature/backup/guide-before-deploy-YYYYMMDDHHMMSS.tar.gz
test -f "$BACKUP"
find "$SITE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
tar -C "$SITE" -xzf "$BACKUP"
docker exec sgs-nature-nginx nginx -t
docker exec sgs-nature-nginx nginx -s reload
```
### 10.2 回滚 Nginx 配置
```bash
set -e
CONFIG=/data/sgs-nature/deploy/nginx.conf
BACKUP=/data/sgs-nature/backup/nginx-before-guide-YYYYMMDDHHMMSS.conf
test -f "$BACKUP"
cat "$BACKUP" > "$CONFIG"
chown root:root "$CONFIG"
chmod 0644 "$CONFIG"
docker exec sgs-nature-nginx nginx -t
docker exec sgs-nature-nginx nginx -s reload
```
回滚时只恢复对应的 `guide` 文件或 Nginx 配置,不要删除整个 `/data/sgs-nature/web`,以免影响 `/dp``/gl` 等现有站点。
## 11. 当前部署记录
2026-09-15
-`8888` 从 HTTP→HTTPS 跳转改为直接提供导览 H5公网 IP 入口为 `http://124.220.83.186:8888/`
- `8888` HTTP 与 `4433` HTTPS 共用同一 server 块,支持域名、公网 IP 和其他 Host
- 配置备份:`/data/sgs-nature/backup/nginx-before-ip-http-20260915114641.conf`
- 候选配置和运行容器的 `nginx -t` 均通过;因单文件 bind mount 的 inode 被替换,仅重启了 `sgs-nature-nginx` 以重新挂载配置
- 公网验证IP HTTP 首页、Manifest、`/app-api/` 均返回 `200`HTTP/HTTPS 私钥路径均返回 `404``4433` HTTPS 与原有 `18101 /dp/` 均返回 `200`
- 域名 HTTP 请求仍被腾讯 DNSPod `webblock` 在源站前重定向;服务器本机以域名 Host 访问 `8888` 返回 `200`
- `guide.whaoyue.com` 当前证书已于 2026-09-05 过期,待单独续期
2026-08-27
- 使用 `pnpm build:test:h5` 构建测试 H5
- 类型检查通过
- Lint 无错误,有 1 个既有未使用变量 warning
- 构建产物 229 个文件,约 204 MB
- 发布到 `/data/sgs-nature/web/guide`
- 复用 `/data/sgs-nature/web` 现有挂载存放证书
- 更新 `/data/sgs-nature/deploy/nginx.conf`
- `sgs-nature-nginx` 执行 `nginx -t` 和 reload
- `8888` 跳转、`4433` HTTPS、API、讲解数据、SDK、Engine、GLB 和私钥保护验证通过
- 原有 `18101 /dp/` 验证通过
本次构建使用测试环境腾讯地图 Key 占位符,不代表正式生产地图 Key 已配置。正式发布前需要注入真实 Key 后重新构建并验证。

View File

@@ -1,534 +0,0 @@
# zjsjgjyyzx.cn 主站 HTTPS 与校验文件部署操作手册
最后更新2026-09-07
## 1. 适用范围
本文档适用于腾讯云测试服务器上的主站域名 `zjsjgjyyzx.cn`,涵盖:
- Lets Encrypt SSL 证书申请与部署
- 保留 HTTP `80` 和 HTTPS `443` 的独立访问能力
- Certbot 自动续期、证书同步和 Nginx reload
- 域名根目录 TXT 校验文件发布
- 配置验证与回滚
本文档**不适用于**导览 H5 站点 `guide.whaoyue.com:8888/4433`。该导览站点使用独立容器 `sgs-nature-nginx`,其部署手册见:
```text
docs/deployment/guide-test-server-nginx-ssl.md
```
## 2. 当前部署目标与拓扑
| 项目 | 当前值 |
| --- | --- |
| 域名 | `zjsjgjyyzx.cn` |
| 服务器公网 IP | `124.220.83.186` |
| HTTP 地址 | `http://zjsjgjyyzx.cn/` |
| HTTPS 地址 | `https://zjsjgjyyzx.cn/` |
| 主 Nginx 容器 | `nginx` |
| Nginx 镜像 | `nginx:latest` |
| Docker 网络 | `bridge` |
| 宿主机端口映射 | `80 -> 80``443 -> 443` |
请求链路:
```text
HTTP 用户 -> zjsjgjyyzx.cn:80 -> nginx Docker 容器 -> 后台主站
HTTPS 用户 -> zjsjgjyyzx.cn:443 -> nginx Docker 容器 -> 后台主站
```
当前要求是 **HTTP 和 HTTPS 均可直接访问**。不要在 HTTP server 中配置强制跳转到 HTTPS除非业务要求变更且完成专项验证。
## 3. 关键目录与文件
### 3.1 Nginx
| 宿主机路径 | 容器路径 | 用途 |
| --- | --- | --- |
| `/data/nginx/conf/nginx.conf` | `/etc/nginx/nginx.conf` | Nginx 主配置 |
| `/data/nginx/conf/conf.d/` | `/etc/nginx/conf.d/` | 站点配置目录 |
| `/data/nginx/html/` | `/usr/share/nginx/html/` | 静态资源根目录 |
| `/data/nginx/html/admin-ui/` | `/usr/share/nginx/html/admin-ui/` | 主站前端根目录 |
| `/data/nginx/ssl/` | `/etc/nginx/ssl/` | Nginx 可读取的证书目录 |
| `/data/nginx/logs/` | `/var/log/nginx/` | Nginx 日志 |
主站 HTTP 配置:
```text
/data/nginx/conf/conf.d/zhjzpt.conf
```
主站 HTTPS 配置:
```text
/data/nginx/conf/conf.d/zhjzpt-ssl.conf
```
### 3.2 证书
Certbot 管理的原始证书:
```text
/etc/letsencrypt/live/zjsjgjyyzx.cn/fullchain.pem
/etc/letsencrypt/live/zjsjgjyyzx.cn/privkey.pem
```
Nginx 容器读取的同步副本:
```text
/data/nginx/ssl/zjsjgjyyzx.cn/fullchain.pem
/data/nginx/ssl/zjsjgjyyzx.cn/privkey.pem
```
权限要求:
```text
fullchain.pem: 0644
privkey.pem: 0600
```
### 3.3 自动续期 Hook
```text
/etc/letsencrypt/renewal-hooks/deploy/zjsjgjyyzx.cn-nginx.sh
```
该 Hook 在证书续期成功后执行以下操作:
1. 将证书复制到 `/data/nginx/ssl/zjsjgjyyzx.cn/`
2. 设置证书与私钥的安全权限。
3. 执行 `docker exec nginx nginx -t`
4. 执行 `docker exec nginx nginx -s reload`
## 4. 部署前检查
### 4.1 DNS 与备案
域名 A 记录必须统一指向:
```text
zjsjgjyyzx.cn -> 124.220.83.186
```
至少使用多个公共 DNS 核对:
```bash
for r in 8.8.8.8 1.1.1.1 223.5.5.5 119.29.29.29; do
printf "$r A="
nslookup -type=A zjsjgjyyzx.cn "$r" 2>/dev/null |
awk '/Address: /{print $2}' | tail -1
done
```
如使用阿里云 DNS可检查权威记录
```bash
dig +short A zjsjgjyyzx.cn @dns9.hichina.com
dig +short A zjsjgjyyzx.cn @dns10.hichina.com
```
所有结果都应为:
```text
124.220.83.186
```
中国大陆服务器使用 HTTP-01 申请证书前,应确保域名备案已通过并已生效。若备案未完成或 DNS 线路未完全刷新Lets Encrypt 可能访问到运营商/平台拦截页,导致验证失败。
### 4.2 Nginx 与端口
```bash
docker inspect nginx --format 'status={{.State.Status}} ports={{json .HostConfig.PortBindings}}'
docker exec nginx nginx -t
ss -lntp | grep -E ':(80|443)([[:space:]]|$)'
```
预期:
- Nginx 容器为 `running`
- 宿主机 `80``443` 都由 Docker 映射监听
- `nginx -t` 通过
### 4.3 ACME 校验路径
HTTP-01 校验规则必须存在于 `zhjzpt.conf``server` 块内:
```nginx
location ^~ /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
try_files $uri =404;
default_type text/plain;
}
```
验证校验路径:
```bash
mkdir -p /data/nginx/html/.well-known/acme-challenge
printf 'acme-check-ok\n' \
> /data/nginx/html/.well-known/acme-challenge/verify-test
curl -i http://zjsjgjyyzx.cn/.well-known/acme-challenge/verify-test
rm -f /data/nginx/html/.well-known/acme-challenge/verify-test
```
必须收到 `200 OK` 和测试文本;校验文件不能被 SPA fallback 返回为首页 HTML。
## 5. 申请 SSL 证书
### 5.1 安装 Certbot
Ubuntu 22.04
```bash
apt-get update -qq
apt-get install -y -qq certbot
```
检查:
```bash
certbot --version
systemctl status certbot.timer --no-pager
```
### 5.2 使用 HTTP-01 Webroot 签发
```bash
certbot certonly \
--webroot \
-w /data/nginx/html \
-d zjsjgjyyzx.cn \
--non-interactive \
--agree-tos \
-m '<运维联系邮箱>' \
--keep-until-expiring
```
成功后检查:
```bash
openssl x509 \
-in /etc/letsencrypt/live/zjsjgjyyzx.cn/fullchain.pem \
-noout -subject -issuer -dates
```
将证书同步到 Nginx 挂载目录:
```bash
install -d -o root -g root -m 0755 /data/nginx/ssl/zjsjgjyyzx.cn
install -o root -g root -m 0644 \
/etc/letsencrypt/live/zjsjgjyyzx.cn/fullchain.pem \
/data/nginx/ssl/zjsjgjyyzx.cn/fullchain.pem
install -o root -g root -m 0600 \
/etc/letsencrypt/live/zjsjgjyyzx.cn/privkey.pem \
/data/nginx/ssl/zjsjgjyyzx.cn/privkey.pem
```
### 5.3 常见申请失败CA 命中旧 IP 或拦截页
若 Certbot 输出中出现非本服务器 IP或类似
```text
Invalid response from https://dnspod.qcloud.com/static/webblock.html
```
不要继续重复申请。先检查:
- 所有 DNS 线路是否统一为 `124.220.83.186`
- 域名备案是否已生效
- `80` 端口是否可从公网访问
- `/.well-known/acme-challenge/` 是否返回实际验证文件
- 是否存在旧 A 记录、CNAME、线路分流或平台拦截
## 6. Nginx 配置
### 6.1 HTTP 80 配置
文件:
```text
/data/nginx/conf/conf.d/zhjzpt.conf
```
HTTP 站点应继续:
```nginx
server {
listen 80;
server_name zjsjgjyyzx.cn 124.220.83.186 _;
# 保留现有静态页面与代理规则
}
```
不要加入以下跳转规则,否则不再满足 HTTP、HTTPS 都直接可访问的要求:
```nginx
return 301 https://$host$request_uri;
```
### 6.2 HTTPS 443 配置
文件:
```text
/data/nginx/conf/conf.d/zhjzpt-ssl.conf
```
HTTPS 配置应与 HTTP 站点保持功能等价,至少包含:
- 静态站点根目录:`/usr/share/nginx/html/admin-ui`
- `/kkfileview/` 代理
- `/dp/` 静态目录
- `/.well-known/acme-challenge/` 规则
- `/admin-api/` 代理
- `/app-api/` 代理
- 根路径 SPA fallback
核心 TLS 配置:
```nginx
server {
listen 443 ssl;
server_name zjsjgjyyzx.cn;
root /usr/share/nginx/html/admin-ui;
index index.html;
charset utf-8;
ssl_certificate /etc/nginx/ssl/zjsjgjyyzx.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/zjsjgjyyzx.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 以下保持与 HTTP 站点相同的静态和代理 location
}
```
配置修改前先备份:
```bash
TS=$(date +%Y%m%d%H%M%S)
cp -p /data/nginx/conf/conf.d/zhjzpt.conf \
"/data/nginx/conf/conf.d/zhjzpt.conf.bak-$TS-before-change"
[ ! -f /data/nginx/conf/conf.d/zhjzpt-ssl.conf ] || \
cp -p /data/nginx/conf/conf.d/zhjzpt-ssl.conf \
"/data/nginx/conf/conf.d/zhjzpt-ssl.conf.bak-$TS"
```
检查并 reload
```bash
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
`nginx -t` 失败时不得 reload。应先恢复备份重新检查通过后再 reload。
## 7. Certbot 自动续期
### 7.1 Deploy Hook 内容
创建:
```text
/etc/letsencrypt/renewal-hooks/deploy/zjsjgjyyzx.cn-nginx.sh
```
内容:
```sh
#!/bin/sh
set -eu
DOMAIN="zjsjgjyyzx.cn"
LIVE="/etc/letsencrypt/live/${DOMAIN}"
TARGET="/data/nginx/ssl/${DOMAIN}"
install -d -o root -g root -m 0755 "$TARGET"
install -o root -g root -m 0644 "$LIVE/fullchain.pem" "$TARGET/fullchain.pem"
install -o root -g root -m 0600 "$LIVE/privkey.pem" "$TARGET/privkey.pem"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
设置权限:
```bash
chmod 700 /etc/letsencrypt/renewal-hooks/deploy/zjsjgjyyzx.cn-nginx.sh
```
### 7.2 测试续期
```bash
certbot renew --dry-run
systemctl is-enabled certbot.timer
systemctl is-active certbot.timer
```
预期:
```text
certbot renew --dry-run模拟续期成功
enabled
active
```
## 8. 域名根目录校验文件
主站根目录是:
```text
/data/nginx/html/admin-ui/
```
发布第三方验证文件时,保持原始文件名和内容。例如文件名为 `example.txt`
```bash
install -o root -g root -m 0644 /tmp/example.txt \
/data/nginx/html/admin-ui/example.txt
```
验证:
```bash
curl -i http://zjsjgjyyzx.cn/example.txt
curl -i https://zjsjgjyyzx.cn/example.txt
```
两个地址都应返回:
```text
HTTP/1.1 200 OK
```
当前已发布的校验文件:
```text
/data/nginx/html/admin-ui/f7TyKy8B8f.txt
```
对应访问地址:
```text
http://zjsjgjyyzx.cn/f7TyKy8B8f.txt
https://zjsjgjyyzx.cn/f7TyKy8B8f.txt
```
## 9. 部署后验证
### 9.1 HTTP 与 HTTPS 首页
```bash
curl -I --max-time 30 http://zjsjgjyyzx.cn/
curl -I --max-time 30 https://zjsjgjyyzx.cn/
```
两者都应返回:
```text
HTTP/1.1 200 OK
```
HTTP 不应返回 `301/302` 到 HTTPS。
### 9.2 TLS 证书
```bash
printf '' | openssl s_client \
-connect zjsjgjyyzx.cn:443 \
-servername zjsjgjyyzx.cn 2>/dev/null |
openssl x509 -noout -subject -issuer -dates
```
预期主体:
```text
subject=CN=zjsjgjyyzx.cn
```
### 9.3 HTTPS API
```bash
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
--max-time 30 \
https://zjsjgjyyzx.cn/app-api/system/menu/list
```
预期:
```text
200 application/json;charset=UTF-8
```
### 9.4 Nginx 与续期
```bash
docker exec nginx nginx -t
certbot certificates
certbot renew --dry-run
```
## 10. 回滚
### 10.1 回滚 HTTPS 配置
如果 HTTPS 新配置导致 `nginx -t` 失败或业务异常:
```bash
set -e
CFGDIR=/data/nginx/conf/conf.d
BACKUP="$CFGDIR/zhjzpt-ssl.conf.bak-YYYYMMDDHHMMSS"
test -f "$BACKUP"
cp -p "$BACKUP" "$CFGDIR/zhjzpt-ssl.conf"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
如果需要临时停用 HTTPS server
```bash
set -e
mv /data/nginx/conf/conf.d/zhjzpt-ssl.conf \
/data/nginx/conf/conf.d/zhjzpt-ssl.conf.disabled
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
这不会影响现有 HTTP 80 站点。
### 10.2 回滚 HTTP 配置
只在误改 `zhjzpt.conf` 时恢复:
```bash
set -e
CFGDIR=/data/nginx/conf/conf.d
BACKUP="$CFGDIR/zhjzpt.conf.bak-YYYYMMDDHHMMSS-before-change"
test -f "$BACKUP"
cp -p "$BACKUP" "$CFGDIR/zhjzpt.conf"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
不要删除 `/data/nginx/html/admin-ui/` 整个目录,以免影响后台前端和根目录校验文件。
## 11. 2026-09-07 实施记录
- 域名备案生效后,权威 DNS 和公共 DNS 均解析到 `124.220.83.186`
- 使用 Certbot HTTP-01 Webroot 模式成功签发 `zjsjgjyyzx.cn` 证书。
- 证书签发机构Lets Encrypt YR2。
- 该证书有效至 `2026-12-06 01:29:53 GMT`
- 新增 `zhjzpt-ssl.conf`,监听 `443 ssl`
- 保留 `zhjzpt.conf``80` HTTP 站点,不执行 HTTP 到 HTTPS 跳转。
- HTTP 首页、HTTPS 首页和 HTTPS `/app-api/system/menu/list` 均验证返回 `200`
- Certbot `renew --dry-run` 成功。
- `certbot.timer` 处于 `enabled``active` 状态。
- 发布根目录校验文件 `f7TyKy8B8f.txt`HTTP 和 HTTPS 均返回 `200`

View File

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

View File

@@ -6,12 +6,14 @@ import type {
import {
type AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import type { MuseumGuideAudioOption } from '@/domain/museum'
import type { AudioPlayTargetType, MuseumGuideAudioOption } from '@/domain/museum'
import { resolveGuideAudioOption } from '@/domain/guideAudioOptions'
export interface GlobalAudioSource {
exhibitId?: string
stopId?: string
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage | string
channelCode?: string
voiceGender?: 'male' | 'female'
@@ -72,10 +74,9 @@ const isAutoplayBlocked = (err: unknown) => {
const syncAudioDuration = () => {
if (!audioElement) return
const mediaDuration = audioElement.duration
duration.value = Number.isFinite(mediaDuration) && mediaDuration > 0
? mediaDuration
: 0
duration.value = Number.isFinite(audioElement.duration)
? audioElement.duration
: currentAudio.value?.duration || 0
}
const ensureAudioElement = () => {
@@ -179,7 +180,7 @@ const stopPlayback = () => {
loading.value = false
pendingLanguage.value = ''
currentTime.value = 0
duration.value = 0
duration.value = currentAudio.value?.duration || 0
}
const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguage) => {
@@ -245,7 +246,7 @@ const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
if (isNewAudio) {
currentTime.value = 0
duration.value = 0
duration.value = audio.duration || 0
}
if (element.getAttribute('src') !== audio.audioUrl) {
@@ -330,7 +331,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
currentAudio.value = currentAudio.value
? {
...currentAudio.value,
id: `unavailable-${source.stopId || source.exhibitId || 'detail'}-${lang}`,
id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
@@ -365,7 +366,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
if (currentAudio.value) {
currentAudio.value = {
...currentAudio.value,
id: `unavailable-${source.stopId || source.exhibitId || 'detail'}-${lang}`,
id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
@@ -466,11 +467,16 @@ const unregisterHost = (hostId: string) => {
}
}
const isCurrentSource = (source: Pick<GlobalAudioSource, 'stopId' | 'lang' | 'channelCode'>) => {
const isCurrentSource = (source: Pick<GlobalAudioSource, 'stopId' | 'targetType' | 'targetId' | 'lang' | 'channelCode'>) => {
if (!currentAudio.value || !currentSource.value) return false
return Boolean(
(!source.stopId || currentSource.value.stopId === source.stopId)
(source.stopId
? currentSource.value.stopId === source.stopId || currentSource.value.targetId === source.stopId
: source.targetType
&& source.targetId
&& currentSource.value.targetType === source.targetType
&& currentSource.value.targetId === source.targetId)
&& (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang)
&& (!source.channelCode || currentSource.value.channelCode === source.channelCode)
)

View File

@@ -1,4 +1,5 @@
import type {
AudioPlayTargetType,
GuideAudioGender,
MuseumGuideAudioOption
} from '@/domain/museum'
@@ -67,6 +68,20 @@ export interface BackendGuideStopLinkedExhibit {
sortOrder?: number | string | null
}
export interface BackendGuideAudioOption {
channelCode?: string | null
displayName?: string | null
version?: string | null
languageCode?: string | null
languageName?: string | null
gender?: string | null
playUrl?: string | null
duration?: number | string | null
format?: string | null
isDefault?: boolean | null
sortOrder?: number | string | null
}
export interface GuideTextVariant {
version: GuideAudioVersion
languageCode: GuideAudioLanguage
@@ -105,6 +120,69 @@ export interface GuideStopDetail {
audioTrackCount: number
}
export interface BackendGuideStopInfo {
available?: boolean
targetType?: string | null
targetId?: string | number | null
resolvedStopId?: string | number | null
stopId?: string | number | null
poiId?: string | number | null
floorId?: string | number | null
mapX?: number | string | null
mapY?: number | string | null
lang?: string | null
title?: string | null
description?: string | null
coverImageUrl?: string | null
galleryUrls?: string | string[] | null
imageStatus?: string | null
imageSource?: string | null
linkedExhibits?: BackendGuideStopLinkedExhibit[] | null
playTargetType?: string | null
playTargetId?: string | number | null
hasAudio?: boolean
hasText?: boolean
supportedLanguages?: string[] | null
audioStatus?: string | null
audioOptions?: BackendGuideAudioOption[] | null
audioOptionCount?: number | string | null
reason?: string | null
linkedExhibitCount?: number | string | null
isSharedStop?: boolean | null
}
export interface BackendAudioPlayInfo {
playable?: boolean
targetType?: string | null
targetId?: string | number | null
lang?: string | null
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
audioId?: string | number | null
title?: string | null
duration?: number | null
format?: string | null
playUrl?: string | null
expiresAt?: string | null
subtitleUrl?: string | null
hasText?: boolean
fallback?: boolean
fallbackReason?: string | null
reason?: string | null
}
export interface BackendAudioTextInfo {
available?: boolean
targetType?: string | null
targetId?: string | number | null
lang?: string | null
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
title?: string | null
text?: string | null
textLength?: number | null
textHash?: string | null
reason?: string | null
}
export interface GuideStopLinkedExhibit {
id: string
name: string
@@ -116,6 +194,68 @@ export interface GuideStopLinkedExhibit {
sortOrder?: number
}
export interface GuideStopInfo {
available: boolean
targetType: AudioPlayTargetType
targetId: string
resolvedStopId?: string
poiId?: string
floorId?: string
mapX?: number
mapY?: number
lang: GuideAudioLanguage
title: string
description?: string
coverImageUrl?: string
galleryUrls: string[]
imageStatus: 'READY' | 'MISSING' | string
imageSource?: string
linkedExhibits: GuideStopLinkedExhibit[]
playTargetType: AudioPlayTargetType
playTargetId: string
hasAudio: boolean
hasText: boolean
supportedLanguages: GuideAudioLanguage[]
audioStatus: 'READY' | 'MISSING' | string
audioOptions: MuseumGuideAudioOption[]
audioOptionCount?: number
reason?: string
linkedExhibitCount?: number
isSharedStop?: boolean
}
export interface GuideAudioPlayInfo {
playable: boolean
targetType: AudioPlayTargetType
targetId: string
lang: GuideAudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED'
audioId?: string
title?: string
duration?: number
format?: string
playUrl?: string
expiresAt?: string
subtitleUrl?: string
hasText: boolean
fallback: boolean
fallbackReason?: string
reason?: string
}
export interface GuideAudioTextInfo {
available: boolean
targetType: AudioPlayTargetType
targetId: string
lang: GuideAudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED'
title?: string
text?: string
textLength?: number
textHash?: string
reason?: string
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
@@ -129,6 +269,11 @@ const normalizeNumber = (value: number | string | null | undefined) => {
return undefined
}
const normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
const normalized = value?.toUpperCase()
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
}
export const normalizeGuideAudioLanguage = (
value: string | null | undefined
): GuideAudioLanguage => {
@@ -138,6 +283,18 @@ export const normalizeGuideAudioLanguage = (
return 'zh-CN'
}
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.from(new Set((languages || [])
.map((language) => {
const normalized = language?.trim().toLowerCase()
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) {
return null
}
return normalizeGuideAudioLanguage(language)
})
.filter(Boolean))) as GuideAudioLanguage[]
)
const normalizeAudioGender = (value: string | null | undefined): GuideAudioGender | null => {
const normalized = value?.trim().toLowerCase()
return normalized === 'male' || normalized === 'female' ? normalized : null
@@ -194,6 +351,32 @@ const normalizeAudioTracks = (items: BackendGuideAudioTrack[] | null | undefined
.filter(Boolean) as GuideAudioTrack[]
)
const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefined): MuseumGuideAudioOption[] => (
(items || [])
.map<MuseumGuideAudioOption | null>((item) => {
const channelCode = item.channelCode?.trim()
const languageCode = normalizeGuideAudioLanguage(item.languageCode)
const gender = normalizeAudioGender(item.gender)
const playUrl = normalizeSameOriginPublicUrl(item.playUrl)
if (!channelCode || !gender || !playUrl) return null
return {
channelCode,
displayName: item.displayName?.trim() || undefined,
version: item.version?.trim() || undefined,
languageCode,
languageName: item.languageName?.trim() || undefined,
gender,
playUrl,
duration: normalizeNumber(item.duration),
format: item.format?.trim() || undefined,
isDefault: item.isDefault === true,
sortOrder: normalizeNumber(item.sortOrder)
}
})
.filter(Boolean) as MuseumGuideAudioOption[]
)
export const toGuideStopDetail = (source: BackendGuideStopDetail): GuideStopDetail => {
const id = stringifyId(source.id)
if (!id) throw new Error('讲解点详情缺少 id')
@@ -231,7 +414,7 @@ export const toGuideStopDetail = (source: BackendGuideStopDetail): GuideStopDeta
}
}
const parseGalleryUrls = (value: BackendGuideStopLinkedExhibit['galleryUrls']) => {
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
if (!value) return []
if (Array.isArray(value)) {
return value.map((entry) => normalizeSameOriginPublicUrl(String(entry))).filter(Boolean)
@@ -254,6 +437,11 @@ const parseGalleryUrls = (value: BackendGuideStopLinkedExhibit['galleryUrls']) =
return []
}
const normalizeNarrationTier = (value: string | null | undefined) => {
const normalized = value?.toUpperCase()
return normalized === 'STANDARD' || normalized === 'EXTENDED' ? normalized : undefined
}
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
(items || [])
.map<GuideStopLinkedExhibit | null>((item) => {
@@ -273,3 +461,114 @@ const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null |
})
.filter(Boolean) as GuideStopLinkedExhibit[]
)
export const toGuideStopInfo = (
source: BackendGuideStopInfo,
fallback: {
targetType: AudioPlayTargetType
targetId: string
lang: GuideAudioLanguage
}
): GuideStopInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
const targetId = stringifyId(source.targetId) || fallback.targetId
const playTargetType = normalizeTargetType(source.playTargetType, targetType)
const playTargetId = stringifyId(source.playTargetId) || targetId
const imageStatus = source.imageStatus || 'MISSING'
const canUseStopImages = imageStatus === 'READY'
const galleryUrls = canUseStopImages ? parseGalleryUrls(source.galleryUrls) : []
const coverImageUrl = canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined
const audioOptions = normalizeAudioOptions(source.audioOptions)
const supportedLanguages = Array.from(new Set([
...normalizeSupportedLanguages(source.supportedLanguages),
...audioOptions.map((option) => option.languageCode as GuideAudioLanguage)
]))
return {
available: source.available === true,
targetType,
targetId,
resolvedStopId: stringifyId(source.resolvedStopId) || stringifyId(source.stopId) || undefined,
poiId: stringifyId(source.poiId) || undefined,
floorId: stringifyId(source.floorId) || undefined,
mapX: normalizeNumber(source.mapX),
mapY: normalizeNumber(source.mapY),
lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
title: source.title?.trim() || '讲解内容',
description: source.description?.trim() || undefined,
coverImageUrl,
galleryUrls,
imageStatus,
imageSource: source.imageSource || undefined,
linkedExhibits: normalizeLinkedExhibits(source.linkedExhibits),
playTargetType,
playTargetId,
hasAudio: source.hasAudio === true,
hasText: source.hasText === true,
supportedLanguages,
audioStatus: source.audioStatus || 'MISSING',
audioOptions,
audioOptionCount: normalizeNumber(source.audioOptionCount),
reason: source.reason || undefined,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
isSharedStop: source.isSharedStop === true
}
}
export const toGuideAudioPlayInfo = (
source: BackendAudioPlayInfo,
fallback: {
targetType: AudioPlayTargetType
targetId: string
lang: GuideAudioLanguage
}
): GuideAudioPlayInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
const targetId = stringifyId(source.targetId) || fallback.targetId
return {
playable: source.playable === true,
targetType,
targetId,
lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
narrationTier: normalizeNarrationTier(source.narrationTier),
audioId: stringifyId(source.audioId) || undefined,
title: source.title?.trim() || undefined,
duration: typeof source.duration === 'number' && Number.isFinite(source.duration) ? source.duration : undefined,
format: source.format?.trim() || undefined,
playUrl: normalizeSameOriginPublicUrl(source.playUrl) || undefined,
expiresAt: source.expiresAt || undefined,
subtitleUrl: normalizeSameOriginPublicUrl(source.subtitleUrl) || undefined,
hasText: source.hasText === true,
fallback: source.fallback === true,
fallbackReason: source.fallbackReason || undefined,
reason: source.reason || undefined
}
}
export const toGuideAudioTextInfo = (
source: BackendAudioTextInfo,
fallback: {
targetType: AudioPlayTargetType
targetId: string
lang: GuideAudioLanguage
}
): GuideAudioTextInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
const targetId = stringifyId(source.targetId) || fallback.targetId
return {
available: source.available === true,
targetType,
targetId,
lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
narrationTier: normalizeNarrationTier(source.narrationTier),
title: source.title?.trim() || undefined,
text: source.text || undefined,
textLength: typeof source.textLength === 'number' && Number.isFinite(source.textLength) ? source.textLength : undefined,
textHash: source.textHash || undefined,
reason: source.reason || undefined
}
}

View File

@@ -0,0 +1,15 @@
import type {
ExplainGuideStop
} from './museum'
export interface ExplainDetailTarget {
targetType: 'STOP'
targetId: string
}
export const normalizeExplainDetailTargetFromGuideStop = (
guideStop: Pick<ExplainGuideStop, 'id'>
): ExplainDetailTarget => ({
targetType: 'STOP',
targetId: String(guideStop.id)
})

View File

@@ -246,6 +246,7 @@ const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'en-US': false
})
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const shouldAutoplayOnEntry = ref(false)
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
@@ -352,7 +353,7 @@ const isCurrentDetailAudioTarget = computed(() => {
return Boolean(
globalAudioPlayer.currentAudio.value
&& source?.stopId === stopId
&& (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId))
)
})
let detailAudioClosedOnExit = false
@@ -372,9 +373,14 @@ const formatDetailAudioTime = (seconds?: number) => {
return `${minutes}:${remainSeconds.toString().padStart(2, '0')}`
}
const detailAudioDurationSeconds = computed(() => (
isCurrentDetailAudioTarget.value ? globalAudioPlayer.duration.value : 0
))
const detailAudioDurationSeconds = computed(() => {
if (!isCurrentDetailAudioTarget.value) return exhibit.value.audio.duration || 0
return globalAudioPlayer.duration.value
|| globalAudioPlayer.currentAudio.value?.duration
|| exhibit.value.audio.duration
|| 0
})
const detailAudioCurrentSeconds = computed(() => (
isCurrentDetailAudioTarget.value ? globalAudioPlayer.currentTime.value : 0
))
@@ -414,7 +420,8 @@ const isCurrentDetailAudioError = computed(() => {
const source = globalAudioPlayer.currentSource.value
return Boolean(
globalAudioPlayer.error.value
&& source?.stopId === currentAudioTarget.value.stopId
&& (source?.stopId === currentAudioTarget.value.stopId
|| (source?.targetType === 'STOP' && source.targetId === currentAudioTarget.value.stopId))
)
})
const isDetailAudioLoading = computed(() => (
@@ -525,6 +532,8 @@ onLoad(async (options: any = {}) => {
return
}
const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
const rawAutoplay = Array.isArray(options.autoplay) ? options.autoplay[0] : options.autoplay
shouldAutoplayOnEntry.value = rawAutoplay === '1' || rawAutoplay === 'true'
const lang = typeof rawLang === 'string'
? normalizeGuideAudioLanguage(rawLang)
: 'zh-CN'
@@ -539,6 +548,18 @@ onLoad(async (options: any = {}) => {
poiId: Array.isArray(options.poiId) ? options.poiId[0] : options.poiId
}
await loadExplainDetail(detailEntryRequest.value, lang)
if (shouldAutoplayOnEntry.value) {
shouldAutoplayOnEntry.value = false
try {
await handlePlayAudio()
} catch (error) {
console.warn('自动播放讲解失败,将等待用户手动播放:', error)
uni.showToast({
title: '点击底部播放键开始讲解',
icon: 'none'
})
}
}
} catch (error) {
console.error('讲解详情加载失败:', error)
detailState.value = 'error'
@@ -725,7 +746,7 @@ watch(
if (
isAudioLanguage(lang)
&& source?.stopId === stopId
&& (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId))
) {
void handleLanguageChange(lang, { syncAudio: false })
}

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
import { dataSourceConfig } from '@/config/dataSource'
import {
toGuideStopDetail,
toGuideStopInfo,
type BackendGuideStopDetail,
type BackendGuideStopInfo,
type GuideAudioLanguage,
type GuideAudioVersion,
type GuideStopDetail
type GuideStopDetail,
type GuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter'
export type AudioLanguage = GuideAudioLanguage
@@ -16,6 +19,7 @@ export interface GetStopDetailOptions {
export interface AudioPlayInfoRepository {
getStopDetail(stopId: string, options?: GetStopDetailOptions): Promise<GuideStopDetail>
getStopInfoByCode(code: string, lang?: AudioLanguage): Promise<GuideStopInfo>
clearCache(stopId?: string): void
}
@@ -25,6 +29,19 @@ interface GuideStopDetailResponse {
data?: BackendGuideStopDetail
}
interface GuideStopInfoResponse {
code: number
msg?: string
data?: BackendGuideStopInfo
}
export class GuideApiError extends Error {
constructor(message: string, readonly code?: number) {
super(message)
this.name = 'GuideApiError'
}
}
interface CacheEntry<T> {
value: T
expiresAt: number
@@ -119,6 +136,25 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
}
}
async getStopInfoByCode(code: string, lang: AudioLanguage = dataSourceConfig.audioLanguage): Promise<GuideStopInfo> {
const normalizedCode = String(code || '').trim()
if (!normalizedCode) throw new GuideApiError('讲解编号不能为空', 1020007000)
const params = new URLSearchParams({ code: normalizedCode, lang })
const response = await requestJson<GuideStopInfoResponse>(
`${resolveAppApiBaseUrl()}/gis/guide/stop/info-by-code?${params.toString()}`
)
if (response.code !== 0 || !response.data) {
throw new GuideApiError(response.msg || '编号讲解查询失败', response.code)
}
return toGuideStopInfo(response.data, {
targetType: 'STOP',
targetId: '',
lang
})
}
async getStopDetail(stopId: string, options: GetStopDetailOptions = {}): Promise<GuideStopDetail> {
const normalizedStopId = String(stopId || '').trim()
if (!normalizedStopId) throw new Error('讲解点 ID 不能为空')
@@ -137,9 +173,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
`${resolveAppApiBaseUrl()}/gis/guide/stops/${encodeURIComponent(normalizedStopId)}?${params.toString()}`
).then((response) => {
if (response.code !== 0 || !response.data) {
const error = new Error(response.msg || '讲解点详情加载失败')
;(error as Error & { code?: number }).code = response.code
throw error
throw new GuideApiError(response.msg || '讲解点详情加载失败', response.code)
}
const detail = toGuideStopDetail(response.data)
if (epoch === this.epoch) this.setCached(normalizedStopId, version, detail)

View File

@@ -1,4 +1,5 @@
import type {
AudioPlayTargetType,
ExplainBusinessUnit,
ExplainGuideStop,
ExplainGuideStopPage,
@@ -42,6 +43,8 @@ export interface ExplainAudioSelection {
export interface ExplainDetailEntryRequest {
exhibitId: string
stopId?: string
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage
version?: GuideAudioVersion
// Navigation context is non-authoritative and keeps deep links independent of catalog loading.
@@ -75,6 +78,11 @@ export interface ExplainHallSummary {
guideStopCount: number
}
export interface ExplainCodeResolution {
code: string
stopId: string
}
export class ExplainUseCase {
constructor(
private readonly explain: ExplainRepository = explainRepository,
@@ -96,7 +104,7 @@ export class ExplainUseCase {
}
private resolveDetailStopId(request: ExplainDetailEntryRequest) {
return String(request.stopId || request.exhibitId || '').trim()
return String(request.stopId || request.targetId || request.exhibitId || '').trim()
}
private async resolveStaticDetailFallback(
@@ -270,6 +278,15 @@ export class ExplainUseCase {
})
}
async resolveExplainCode(code: string, lang: AudioLanguage = dataSourceConfig.audioLanguage as AudioLanguage): Promise<ExplainCodeResolution> {
const stopInfo = await this.audioPlayInfo.getStopInfoByCode(code, lang)
const stopId = stopInfo.resolvedStopId || stopInfo.playTargetId || stopInfo.targetId
if (!stopInfo.available || !stopId) {
throw new Error('该编号的讲解内容暂不可用')
}
return { code: code.trim(), stopId }
}
async enterExplainDetail(request: ExplainDetailEntryRequest) {
const stopId = this.resolveDetailStopId(request)
if (!stopId) throw new Error('讲解点 ID 不能为空')

View File

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

View File

@@ -61,7 +61,7 @@ test('hall goes directly to paged guide objects without outline requests', async
await page.mouse.wheel(0, 10000)
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
await page.getByText('讲解对象1', { exact: true }).click()
await expect(page).toHaveURL(/pages\/exhibit\/detail\?.*stopId=stop-1/)
await expect(page).toHaveURL(/pages\/exhibit\/detail\?.*targetType=STOP.*targetId=target-1/)
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
})

View File

@@ -68,6 +68,58 @@ describe('DefaultAudioPlayInfoRepository unified stop detail', () => {
expect(request).toHaveBeenCalledTimes(2)
})
it('resolves a museum code through info-by-code and preserves the code string', async () => {
const requestUrls: string[] = []
vi.stubGlobal('uni', {
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
requestUrls.push(url)
success({
statusCode: 200,
data: {
code: 0,
data: {
available: true,
targetType: 'STOP',
targetId: '865546652298080256',
resolvedStopId: '865546652298080256',
lang: 'zh-CN',
title: '手持折射望远镜',
imageStatus: 'READY',
playTargetType: 'STOP',
playTargetId: '865546652298080256',
hasAudio: true,
supportedLanguages: ['zh-CN', 'yue-HK'],
audioStatus: 'READY'
}
}
})
}
})
const repository = new DefaultAudioPlayInfoRepository()
const result = await repository.getStopInfoByCode('0101', 'zh-CN')
expect(requestUrls).toEqual([
'/app-api/gis/guide/stop/info-by-code?code=0101&lang=zh-CN'
])
expect(result.resolvedStopId).toBe('865546652298080256')
expect(result.title).toBe('手持折射望远镜')
})
it('preserves info-by-code business error codes', async () => {
vi.stubGlobal('uni', {
request: ({ success }: { success: (response: unknown) => void }) => {
success({ statusCode: 200, data: { code: 1020007001, msg: '馆方讲解点编号不存在', data: null } })
}
})
const repository = new DefaultAudioPlayInfoRepository()
await expect(repository.getStopInfoByCode('9999')).rejects.toMatchObject({
message: '馆方讲解点编号不存在',
code: 1020007001
})
})
it('surfaces a business missing-detail response as a detail error', async () => {
vi.stubGlobal('uni', {
request: ({ success }: { success: (response: unknown) => void }) => {

View File

@@ -150,7 +150,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.get('.detail-title').text()).toBe('Classical Astrolabe')
@@ -164,7 +164,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.get('.content-section').classes()).toContain('is-english')
expect(wrapper.find('.section-title').exists()).toBe(false)
expect(wrapper.get('.section-text').text()).toContain('detailed English narration')
expect(wrapper.get('.detail-audio-time').text()).toBe('0:00 / 0:00')
expect(wrapper.get('.detail-audio-time').text()).toBe('0:00 / 1:21')
expect(wrapper.get('.detail-audio-speed').attributes('aria-label')).toBe('播放速度 1.0倍')
})
@@ -174,7 +174,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
const state = wrapper.get('[data-testid="exhibit-detail-state"]')
@@ -197,7 +197,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
const labels = wrapper.findAll('.language-option-text').map((node) => node.text())
@@ -214,7 +214,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'zh-CN', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
@@ -231,7 +231,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' }))
@@ -246,7 +246,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
await wrapper.get('.detail-audio-play').trigger('tap')
@@ -256,15 +256,35 @@ describe('讲解详情音频优先布局', () => {
}), expect.objectContaining({ displayMode: 'mini' }))
})
it('编号入口加载详情后自动发起一次播放', async () => {
mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({
id: exhibit.id,
stopId: 'stop-1',
autoplay: '1',
entry: 'code'
})
await flushPromises()
expect(mocks.play).toHaveBeenCalledTimes(1)
expect(mocks.play).toHaveBeenCalledWith(
expect.objectContaining({ audioUrl: exhibit.audioUrl }),
expect.objectContaining({ displayMode: 'mini' })
)
})
it('加载中使用同一高度底栏和不确定进度', async () => {
audioState.currentAudio.value = { id: 'audio-1' }
audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US' }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
audioState.loading.value = true
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading')
@@ -287,7 +307,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-unavailable')
@@ -297,7 +317,7 @@ describe('讲解详情音频优先布局', () => {
it('播放失败显示错误并从当前详情音轨重试', async () => {
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US' }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
audioState.error.value = '音频暂时无法播放'
mocks.selectAudioForExplainDetail.mockResolvedValue({
playable: true,
@@ -309,7 +329,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-failed')
expect(wrapper.find('.detail-audio-retry').exists()).toBe(false)
@@ -348,7 +368,7 @@ describe('讲解详情音频优先布局', () => {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(wrapper.get('.detail-audio-speed').text()).toBe('1.0')
@@ -380,12 +400,12 @@ describe('讲解详情音频优先布局', () => {
it('离开详情页时立即关闭当前讲解播放器', async () => {
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US' }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', stopId: 'stop-1' })
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
mocks.onHideHandler?.()
@@ -403,7 +423,8 @@ describe('讲解详情音频优先布局', () => {
await mocks.onLoadHandler?.({
id: exhibit.id,
lang: 'en-US',
stopId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1',
hallId: 'hall-1',
hallName: '%2525E6%252581%252590%2525E9%2525BE%252599%2525E5%25258E%252585'
})

View File

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

View File

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

View File

@@ -33,11 +33,28 @@ const createUseCase = (detail: GuideStopDetail) => {
getExhibitById: vi.fn().mockResolvedValue(null),
listExplainExhibits: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const audio = { getStopDetail: vi.fn().mockResolvedValue(detail) } as unknown as AudioPlayInfoRepository
const audio = {
getStopDetail: vi.fn().mockResolvedValue(detail),
getStopInfoByCode: vi.fn().mockResolvedValue({
available: true,
resolvedStopId: 'resolved-stop-1',
playTargetId: 'resolved-stop-1',
targetId: 'resolved-stop-1'
})
} as unknown as AudioPlayInfoRepository
return { useCase: new ExplainUseCase(explain, audio), explain, audio }
}
describe('ExplainUseCase unified stop detail', () => {
it('resolves the museum code to the backend stop ID', async () => {
const { useCase, audio } = createUseCase(createDetail())
const result = await useCase.resolveExplainCode('1001', 'zh-CN')
expect(audio.getStopInfoByCode).toHaveBeenCalledWith('1001', 'zh-CN')
expect(result).toEqual({ code: '1001', stopId: 'resolved-stop-1' })
})
it('uses stopId once in remote mode without requesting a catalog fallback', async () => {
const originalMode = dataSourceConfig.explainContentMode
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })

View File

@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
toGuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter'
import {
canToggleGuideAudioGender,
GUIDE_AUDIO_LANGUAGE_OPTIONS,
@@ -43,6 +46,36 @@ const audioOptions: MuseumGuideAudioOption[] = [
]
describe('guide audio options', () => {
it('parses directly playable audio options from stop-info', () => {
const stopInfo = toGuideStopInfo({
available: true,
targetType: 'STOP',
targetId: '100',
lang: 'zh-CN',
title: '测试讲解',
audioStatus: 'READY',
audioOptionCount: 5,
audioOptions: audioOptions.map((option) => ({
...option,
duration: 120
}))
}, {
targetType: 'STOP',
targetId: '100',
lang: 'zh-CN'
})
expect(stopInfo.audioOptions).toHaveLength(5)
expect(stopInfo.audioOptions[1]).toMatchObject({
channelCode: 'standard.zh-CN.male',
languageCode: 'zh-CN',
gender: 'male',
playUrl: '/audio/zh-male.mp3',
duration: 120
})
expect(stopInfo.supportedLanguages).toEqual(['zh-CN', 'en-US', 'yue-HK'])
})
it('keeps tabs in Chinese, English, Cantonese order and defaults Chinese and English to male', () => {
expect(GUIDE_AUDIO_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual([
'zh-CN',