Compare commits

...

13 Commits

Author SHA1 Message Date
Daniel
95b36978ce fix:优化数据 2026-03-09 10:04:41 +08:00
Daniel
788d09b24b fix:bbug 2026-03-09 09:56:43 +08:00
Daniel
d6426d338e fix:优化项目加载 2026-03-09 09:53:45 +08:00
Daniel
a62b203100 fix:y:优化移动端视角 2026-03-09 09:43:34 +08:00
Daniel
a403c83fe4 fix: 优化整个数据 2026-03-09 09:37:46 +08:00
Daniel
a6d515b6c5 fix: bug 2026-03-09 09:32:19 +08:00
Daniel
e79c84efaf fix:优化整个项目 2026-03-08 22:49:24 +08:00
Daniel
8981e08d0b fix:修复 2026-03-08 21:59:32 +08:00
Daniel
37203a1964 fix:修复渲染报错 2026-03-08 21:57:02 +08:00
Daniel
b05ebb80fa fix:修复报错 2026-03-08 21:52:29 +08:00
Daniel
2dd1117e51 fix: 修复启动脚本不正确的问题 2026-03-08 17:37:02 +08:00
Daniel
06c0e36d92 fix:修改脚本 2026-03-08 17:23:00 +08:00
Daniel
890dea096b fix:优化启动脚本 2026-03-08 00:37:18 +08:00
18 changed files with 656 additions and 50 deletions

View File

@@ -25,8 +25,35 @@ function copyDir(srcDir, destDir) {
}
}
/** 清空目录内容(不删目录本身),避免 EPERM如宝塔 wwwroot 下 dist 无法 rmSync */
function emptyDir(dir) {
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
try {
if (fs.statSync(p).isDirectory()) {
fs.rmSync(p, { recursive: true });
} else {
fs.unlinkSync(p);
}
} catch (e) {
if (e.code !== 'EPERM' && e.code !== 'EACCES') throw e;
}
}
}
function main() {
if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true });
if (fs.existsSync(DIST)) {
try {
fs.rmSync(DIST, { recursive: true });
} catch (e) {
if (e.code === 'EPERM' || e.code === 'EACCES') {
emptyDir(DIST);
} else {
throw e;
}
}
}
fs.mkdirSync(DIST, { recursive: true });
copyFile(path.join(ROOT, 'index.html'), path.join(DIST, 'index.html'));

95
check-nginx-and-api.sh Normal file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# 检查 Nginx 反代配置与本地 API5599是否正常
# 在服务器上执行: ./check-nginx-and-api.sh [Nginx站点配置路径]
# 例: ./check-nginx-and-api.sh /www/server/panel/vhost/nginx/view.airtep.com.conf
PORT="${PORT:-5599}"
NGINX_CONF="${1:-}"
echo "========== 1. 检查 API 是否监听并响应 =========="
if command -v ss >/dev/null 2>&1; then
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
echo "端口 ${PORT} 已在监听。"
else
echo "端口 ${PORT} 未在监听,请先执行: ./start-api.sh 或 ./run.sh"
fi
fi
echo ""
echo "---------- curl http://127.0.0.1:${PORT}/config ----------"
if command -v curl >/dev/null 2>&1; then
code=$(curl -s -o /tmp/check-api-config.txt -w "%{http_code}" "http://127.0.0.1:${PORT}/config" 2>/dev/null)
echo "HTTP 状态码: $code"
if [ "$code" = "200" ]; then
echo "响应内容: $(cat /tmp/check-api-config.txt 2>/dev/null | head -c 200)"
echo ""
echo "[OK] 本机 API 正常。"
else
echo "[失败] 本机 API 未返回 200请启动后端: ./start-api.sh"
fi
rm -f /tmp/check-api-config.txt
else
echo "未安装 curl跳过。"
fi
echo ""
echo "========== 2. 检查 Nginx 反代配置 =========="
if [ -z "$NGINX_CONF" ]; then
echo "未传入 Nginx 配置路径。用法: $0 /www/server/panel/vhost/nginx/view.airtep.com.conf"
echo "请确认站点配置中包含:"
echo " location /api {"
echo " proxy_pass http://127.0.0.1:5599/; # 末尾必须有 /,且建议用 127.0.0.1 不用 localhost"
echo " ..."
echo " }"
exit 0
fi
if [ ! -f "$NGINX_CONF" ]; then
echo "文件不存在: $NGINX_CONF"
exit 1
fi
echo "配置文件: $NGINX_CONF"
echo ""
if grep -q "location /api" "$NGINX_CONF"; then
echo "--- location /api 片段 ---"
sed -n '/location \/api/,/^[[:space:]]*}/p' "$NGINX_CONF" | head -15
if grep -A1 "location /api" "$NGINX_CONF" | grep -q "proxy_pass http://127.0.0.1:5599/"; then
echo ""
echo "[OK] proxy_pass 为 http://127.0.0.1:5599/(正确)。"
elif grep -A1 "location /api" "$NGINX_CONF" | grep -q "proxy_pass.*5599"; then
echo ""
echo "[注意] 请将 proxy_pass 改为: http://127.0.0.1:5599/ (末尾加 /,并用 127.0.0.1"
else
echo ""
echo "[注意] 未找到 proxy_pass 到 5599请检查配置。"
fi
else
echo "[失败] 未找到 location /api请添加反代到 5599。"
fi
echo ""
echo "========== 3. 测试经 Nginx 访问 /api/config本机 HTTP 80==========="
if command -v curl >/dev/null 2>&1; then
code_80=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/api/config" -H "Host: view.airtep.com" 2>/dev/null)
echo "curl http://127.0.0.1/api/config (Host: view.airtep.com) => HTTP $code_80"
[ "$code_80" = "200" ] && echo "[OK] 80 反代正常。" || echo "[失败] 80 返回 $code_80"
fi
echo ""
echo "========== 4. 测试 HTTPS https://view.airtep.com/api/config ==========="
if command -v curl >/dev/null 2>&1; then
code_443=$(curl -sk -o /dev/null -w "%{http_code}" "https://view.airtep.com/api/config" 2>/dev/null)
echo "curl -sk https://view.airtep.com/api/config => HTTP $code_443"
if [ "$code_443" = "200" ]; then
echo "[OK] HTTPS 反代正常。"
else
echo "[失败] HTTPS 返回 $code_443。若 80 正常而 443 为 404说明 443 的 server 块内缺少 location /api 或顺序有误。"
fi
fi
echo ""
echo "========== 5. Nginx 中 location 顺序(前 20 个)==========="
if [ -n "$NGINX_CONF" ] && [ -f "$NGINX_CONF" ]; then
grep -n "location \|server {" "$NGINX_CONF" | head -25
fi

View File

@@ -17,12 +17,13 @@ if ! ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
fi
fi
echo "--- 测试 API ---"
# 独立后端路由为 /config无 /api 前缀Nginx 反代后浏览器请求 /api/config
if command -v curl >/dev/null 2>&1; then
resp=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/api/config" 2>/dev/null)
resp=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/config" 2>/dev/null)
if [ "$resp" = "200" ]; then
echo "GET /api/config 返回 200服务正常。"
echo "GET /config 返回 200服务正常。"
else
echo "GET /api/config 返回 ${resp},请检查服务。"
echo "GET /config 返回 ${resp},请检查服务。"
fi
else
echo "未安装 curl仅检查了端口监听。"

View File

@@ -8,7 +8,7 @@
"image/mobile_u.jpg",
"image/mobile_d.jpg"
],
"title": "四川省广兴镇蔡家 全景图(纪念版)",
"title": "四川省广兴镇蔡家11&2组 全景图(纪念版)",
"autoLoad": true,
"showControls": true,
"hfov": 100,

View File

@@ -1,8 +1,9 @@
# 将 /api 请求转发到本机 Node 后端(端口与 start-api.sh 一致,默认 5599
# 注意proxy_pass 末尾加 /,这样 /api/config 会转发为 /config后端路由无 /api 前缀)
# 把下面这段加入 server { ... } 内,建议放在 root 指令之后、其他 location 之前
location /api {
proxy_pass http://127.0.0.1:5599;
proxy_pass http://127.0.0.1:5599/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -0,0 +1,102 @@
# view.airtep.com 完整 server 配置示例80+443 合一,/api 反代 5599
# 在宝塔 → 网站 → view.airtep.com → 设置 → 配置文件 中,用本文件内容替换整个 server { } 块后保存,再 nginx -t && nginx -s reload
server {
listen 80;
listen 443 ssl http2;
server_name view.airtep.com;
index index.php index.html index.htm default.php default.htm default.html;
root /www/wwwroot/view.airtep.com/dist/;
#CERT-APPLY-CHECK--START
include /www/server/panel/vhost/nginx/well-known/view.airtep.com.conf;
#CERT-APPLY-CHECK--END
include /www/server/panel/vhost/nginx/extension/view.airtep.com/*.conf;
#SSL-START
ssl_certificate /www/server/panel/vhost/cert/view.airtep.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/view.airtep.com/privkey.pem;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
#SSL-END
#ERROR-PAGE-START
error_page 404 /404.html;
#ERROR-PAGE-END
#PHP-INFO-START
include enable-php-00.conf;
#PHP-INFO-END
#REWRITE-START
include /www/server/panel/vhost/rewrite/view.airtep.com.conf;
#REWRITE-END
# ---------- 先配路由与反代,再配禁止规则 ----------
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:5599/;
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;
}
location = /edit {
try_files /index.html =404;
}
location /ws {
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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_read_timeout 86400;
}
# 禁止访问的敏感文件(不含 api.config.json、config.json
location ~* (\.user\.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
{
return 404;
}
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
return 404;
}
location ~ \.well-known {
allow all;
}
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
expires 30d;
error_log /dev/null;
access_log /dev/null;
}
location ~ .*\.(js|css)?$ {
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
access_log /www/wwwlogs/view.airtep.com.log;
error_log /www/wwwlogs/view.airtep.com.error.log;
}

View File

@@ -0,0 +1,19 @@
# view.airtep.com 必须有的两段配置(避免 404
# 在宝塔 → 网站 → view.airtep.com → 设置 → 配置文件 中,
# 在 server { } 内、且不要有重复或冲突的 location /api 或 location /api/ 。
# 只保留下面这一种 /api 反代,删掉任何 proxy_pass 到 3003 或别的端口的 location /api/ 。
# 1) 把 /api 开头的请求转到本机 5599本项目 API
location /api {
proxy_pass http://127.0.0.1:5599/;
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;
}
# 2) 若 api.config.json 被「禁止访问」规则误拦,可加下面这段(二选一,一般 root 正确即可)
# location = /api.config.json {
# alias /www/wwwroot/view.airtep.com/dist/api.config.json;
# }

View File

@@ -15,7 +15,7 @@ server {
}
location /api {
proxy_pass http://127.0.0.1:5599;
proxy_pass http://127.0.0.1:5599/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -0,0 +1,15 @@
# 全景视角与移动端缩放说明
## 拉近 / 拉远 与 FOV视野角
在本项目使用的 Pannellum 里,视角由 **hfov**(水平视野角,单位度)控制。按你的定义:
- **拉近** = hfov **变大** → 视野变宽(例如从 50 调到 90
- **拉远** = hfov **变小** → 视野变窄、看得更近/更局部(例如从 90 调到 40
对应到手势:双指**捏合**时 hfov 变大(拉近/视野变宽),双指**张开**时 hfov 变小(拉远/看得更近)。移动端已优化双指缩放的跟手程度。
## 移动端双指缩放优化
- 已把 Pannellum 内双指 pinch 的缩放系数从 **0.1** 调整为 **0.22**,相同手指移动距离下 hfov 变化更大,相机更跟手。
- 移动端 hfov 范围:**minHfov 28**(最大拉近)、**maxHfov 105**(最大拉远),可在 `index.html` / `src/main.js``buildViewer` 里按需修改。

View File

@@ -0,0 +1,86 @@
# 前端与接口关系说明
## 一、架构关系
```
浏览器访问 https://view.airtep.com
Nginx (80/443)
├─ / → root dist/ → index.html、config.json、api.config.json、lib/、image/
├─ /api/* → 反代到 127.0.0.1:5599/ → 后端 Node无 /api 前缀)
└─ 其他静态 → dist/ 下文件
```
- **前端**:纯静态,来自 `dist/`index.html、config.json、api.config.json、lib/、image/)。由 Nginx 的 `root` + `location /` 提供。
- **接口**Node 进程监听 **5599**,路由为 **/config、/stats、/view、/join、/comments** 等(**没有 /api 前缀**)。
- **Nginx 反代**:必须用 `location /api``proxy_pass http://127.0.0.1:5599/;`(末尾有 `/`),这样请求 `/api/config` 会被转成后端的 `/config`
## 二、为何出现 404
`nginx -T` 里会看到**多个** `location /api`**location /api/**,来自不同站点或 include
- **location /api**4 个字符)→ 反代到 5599 ✅
- **location /api/**5 个字符)→ 可能反代到 3000、3001、9091 等 ❌
Nginx 对**前缀 location** 选**最长匹配**。请求 `/api/config` 时,若当前 server 里**同时存在** `location /api``location /api/`,会匹配到 **location /api/**。若这个 `/api/` 指向的是 3001 等其它服务,就会返回 404。
因此:**view.airtep.com 的 server 里只能保留「反代到 5599」的那一个 location且不要有「location /api/」指向其它端口。**
## 三、正确配置view.airtep.com 仅此一段 /api
在 view.airtep.com 的 server 块内:
- **只保留**下面这一段,**不要**再写 `location /api/` 或其它端口的 `/api`
```nginx
location /api {
proxy_pass http://127.0.0.1:5599/;
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;
}
```
- 若存在 **include**(例如 `extension/view.airtep.com/*.conf`),检查这些 conf 里是否带有 **location /api/****location /api**。若有且指向 3000/3001/9091 等,要在 view.airtep.com 的站点下**删掉或注释**这些 include 中的 `/api` 配置,或在该 include 里**只保留**上面这段 5599 反代。
## 四、检查与验证
在服务器上:
```bash
# 1. 本机 API 正常
curl -s http://127.0.0.1:5599/config
# 应返回:{"danmakuEnabled":false,"danmakuPosition":"top"}
# 2. 查看 view.airtep.com 所在 server 块中的 /api 配置(行号供对照)
grep -n "location /api\|proxy_pass" /www/server/panel/vhost/nginx/view.airtep.com.conf
# 3. 查看是否有 extension 覆盖 /api
ls /www/server/panel/vhost/nginx/extension/view.airtep.com/
cat /www/server/panel/vhost/nginx/extension/view.airtep.com/*.conf
# 4. 重载后测 80 和 443
nginx -t && nginx -s reload
curl -s -o /dev/null -w "80: %{http_code}\n" "http://127.0.0.1/api/config" -H "Host: view.airtep.com"
curl -sk -o /dev/null -w "443: %{http_code}\n" "https://view.airtep.com/api/config"
# 两者都应为 200
```
## 五、后端数据落库与前端显示
- **存储**:后端使用 `server/data/store.json`JSON 文件),路径由 `server/db.js``__dirname` 决定,与进程当前工作目录无关;每次修改(浏览/点赞/分享/加入离开/评论)都会调用 `save()` 落盘。
- **验证落库**:在服务器上查看 `server/data/store.json` 或请求 `curl -s http://127.0.0.1:5599/stats` 即可确认数据是否写入。
- **前端显示异常**:若接口正常但页面上的「播放次数、点赞、分享、评论、在看」出现被置 0 或错乱,多为前端用「部分接口返回值」直接刷新整块统计导致(例如 `/join` 只返回 `watchingNow`,却用 `updateStatsUI({ watchingNow })` 把其他项也刷成 0。处理方式前端维护一份 `lastStats`,每次只合并本次接口返回的字段再刷新 UI避免部分更新覆盖其它统计。
## 六、小结
| 角色 | 说明 |
|------------|------|
| 前端 | Nginx 提供 dist/ 下静态资源,同域访问。 |
| 接口 | Node 监听 5599路由为 /config、/stats 等(无 /api 前缀)。 |
| Nginx 反代 | 仅用 **location /api****proxy_pass http://127.0.0.1:5599/**,且本站点内不要有 **location /api/** 指向其它端口。 |
| 404 原因 | 存在 **location /api/** 且指向 3001 等,优先于 **location /api**,请求被转到错误后端。 |

View File

@@ -35,12 +35,12 @@
2. 左侧选 **反向代理****添加反向代理**
3. 填写:
- **代理名称**:随意,如 `api`
- **目标 URL**`http://127.0.0.1:5599`
- **目标 URL**`http://127.0.0.1:5599/`**末尾必须带 `/`**,这样 `/api/config` 会转成后端的 `/config`,因本后端路由无 `/api` 前缀)
- **代理目录**`/api`(表示只把 `/api` 开头的请求转给后端)
- **发送域名**:默认 `$host` 即可
4. 保存后,宝塔会自动改写 Nginx 配置并重载,无需手改配置文件
4. 保存后,宝塔生成的 `proxy_pass` 没有末尾 `/`,请到 **设置 → 配置文件** 中把 `proxy_pass http://127.0.0.1:5599;` 改为 `proxy_pass http://127.0.0.1:5599/;`,再重载 Nginx
效果:访问 `http://你的域名/api/config`Nginx 会转发到 `http://127.0.0.1:5599/api/config`
效果:访问 `http://你的域名/api/config`Nginx 会转发到 `http://127.0.0.1:5599/config`,后端才能正确返回
---
@@ -103,7 +103,7 @@ export API_BASE_URL="http://你的域名或IP:5599"
1. **前端**:浏览器访问 `http://你的域名`,应能打开全景页。
2. **API**
- 同机执行:`curl -s http://127.0.0.1:5599/api/config`,应返回 JSON。
- 同机执行:`curl -s http://127.0.0.1:5599/config`,应返回 JSON。
- 或访问:`http://你的域名/api/config`,应返回相同内容(说明反代生效)。
若 `/api/config` 报 502多半是 5599 端口未启或未监听:用方式 A 再跑一次 `./run.sh`,或用方式 B 在宝塔 Node 项目中启动/重启。
@@ -123,7 +123,7 @@ location / {
}
location /api {
proxy_pass http://127.0.0.1:5599;
proxy_pass http://127.0.0.1:5599/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -141,7 +141,7 @@ location /api {
| 步骤 | 说明 |
|------|------|
| 1. 网站根目录 | 指向 `项目根目录/dist` |
| 2. 反向代理 | 代理目录 `/api` → 目标 `http://127.0.0.1:5599` |
| 2. 反向代理 | 代理目录 `/api` → 目标 `http://127.0.0.1:5599/`(末尾带 `/` |
| 3. 后端保活 | 用 `./run.sh`(或 `USE_PM2=1 ./run.sh`),或用宝塔 Node 项目托管 `server/index.js` 端口 5599 |
按上述配置后,在宝塔中即可正常访问前端,且 `/api` 请求会由 Nginx 转发到本机 Node 服务并正常运行。

View File

@@ -85,6 +85,7 @@
}
.bottom_right .CustomButton:hover { background: rgba(0,0,0,.5); }
.bottom_right .CustomButton.selected { border-color: #fa6400; }
.CustomButton .btn-icon { width: 22px; height: 22px; margin-bottom: 2px; flex-shrink: 0; }
.CustomButton .btn-label { display: block; }
.safeHeight { height: env(safe-area-inset-bottom, 0); }
@@ -113,9 +114,16 @@
.modal-box .btns .btn-ok { background: #fa6400; color: #fff; }
.modal-box .btns .btn-cancel { background: #555; color: #fff; }
/* 隐藏 Pannellum 左侧控件及左下角标志 */
/* 拖拽顺滑:渲染层走 GPU 合成,减少卡顿 */
#panorama .pnlm-render-container { transform: translateZ(0); backface-visibility: hidden; }
#panorama .pnlm-container { transform: translateZ(0); }
/* 隐藏 Pannellum 左侧控件及左下角标志;左下角标题块保留原样式,仅做排版对齐(与顶部作者左侧对齐、不贴边) */
#panorama .pnlm-controls-container { display: none !important; }
#panorama .pnlm-about-msg { display: none !important; }
#panorama .pnlm-panorama-info {
left: max(12px, env(safe-area-inset-left)) !important;
padding-left: 12px !important;
}
</style>
</head>
<body>
@@ -153,10 +161,22 @@
<div class="bottom">
<div class="bottomWp">
<div class="bottom_right">
<div class="CustomButton" id="btnIntro" title="简介"><span class="btn-label">简介</span></div>
<div class="CustomButton" id="btnShare" title="分享"><span class="btn-label">分享</span></div>
<div class="CustomButton" id="btnLike" title="赞"><span class="btn-label"></span></div>
<div class="CustomButton" id="btnComment" title="留言"><span class="btn-label">留言</span></div>
<div class="CustomButton" id="btnIntro" title="简介">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="16" y2="11"/></svg>
<span class="btn-label">简介</span>
</div>
<div class="CustomButton" id="btnShare" title="分享">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
<span class="btn-label">分享</span>
</div>
<div class="CustomButton" id="btnLike" title="赞">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
<span class="btn-label"></span>
</div>
<div class="CustomButton" id="btnComment" title="留言">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="btn-label">留言</span>
</div>
</div>
</div>
<div class="safeHeight"></div>
@@ -198,6 +218,7 @@
var viewerId = null;
var statsInterval = null;
var danmakuLastId = 0;
var lastStats = { viewCount: 0, likeCount: 0, shareCount: 0, commentCount: 0, watchingNow: 0 };
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -229,22 +250,49 @@
if (container) container.innerHTML = '';
}
function isMobileView() {
return (typeof window.matchMedia !== 'undefined' && window.matchMedia('(max-width: 768px)').matches) ||
(typeof window.orientation !== 'undefined') || ('ontouchstart' in window && window.innerWidth <= 1024);
}
function buildViewer(config) {
destroyViewer();
hideError();
if (container) container.style.display = 'block';
config.autoLoad = config.autoLoad !== false;
config.hfov = config.hfov || 100;
config.minHfov = config.minHfov != null ? config.minHfov : 50;
config.maxHfov = config.maxHfov != null ? config.maxHfov : 120;
var mobile = isMobileView();
var targetHfov;
if (mobile) {
targetHfov = config.hfov != null ? Math.min(config.hfov, 75) : 72;
config.minHfov = config.minHfov != null ? config.minHfov : 28;
config.maxHfov = config.maxHfov != null ? Math.min(config.maxHfov, 108) : 105;
config.hfov = config.maxHfov;
} else {
targetHfov = config.hfov || 100;
config.minHfov = config.minHfov != null ? config.minHfov : 50;
config.maxHfov = config.maxHfov != null ? config.maxHfov : 120;
config.hfov = config.maxHfov;
}
config.showZoomCtrl = false;
config.compass = false;
config.showFullscreenCtrl = false;
config.draggable = true;
config.friction = 0.06;
config.touchPanSpeedCoeffFactor = 1.2;
try {
currentViewer = pannellum.viewer('panorama', config);
var fisheyeDone = false;
function runFisheyeEnter() {
if (fisheyeDone || !currentViewer) return;
fisheyeDone = true;
if (currentViewer.getHfov && currentViewer.getHfov() === config.maxHfov) {
currentViewer.setHfov(targetHfov, 1500);
}
}
currentViewer.on('error', function(err) {
showError('全景图加载失败:' + (err && err.message ? err.message : '未知错误'));
});
currentViewer.on('load', runFisheyeEnter);
setTimeout(runFisheyeEnter, 500);
} catch (e) {
showError('创建查看器失败:' + (e.message || e));
}
@@ -252,16 +300,19 @@
function updateStatsUI(stats) {
if (!stats) return;
var keys = ['viewCount', 'likeCount', 'shareCount', 'commentCount', 'watchingNow'];
keys.forEach(function(k) { if (stats[k] !== undefined) lastStats[k] = stats[k]; });
var s = lastStats;
var w = document.getElementById('watchingNow');
if (w) w.textContent = (stats.watchingNow || 0) + ' 人在看';
if (w) w.textContent = (s.watchingNow || 0) + ' 人在看';
var v = document.getElementById('viewCount');
if (v) v.textContent = '共 ' + formatNum(stats.viewCount || 0) + ' 次播放';
if (v) v.textContent = '共 ' + formatNum(s.viewCount || 0) + ' 次播放';
var l = document.getElementById('likeCount');
if (l) l.textContent = formatNum(stats.likeCount || 0);
var s = document.getElementById('shareCount');
if (s) s.textContent = formatNum(stats.shareCount || 0);
if (l) l.textContent = formatNum(s.likeCount || 0);
var shareEl = document.getElementById('shareCount');
if (shareEl) shareEl.textContent = formatNum(s.shareCount || 0);
var c = document.getElementById('commentCount');
if (c) c.textContent = formatNum(stats.commentCount || 0);
if (c) c.textContent = formatNum(s.commentCount || 0);
}
function formatNum(n) {
n = Number(n);
@@ -392,7 +443,7 @@
function loadFromConfig(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'config.json', true);
xhr.open('GET', 'config.json?v=' + (window.__CACHE_VERSION__ || ''), true);
xhr.onload = function() {
if (xhr.status !== 200) { if (cb) cb(null); return; }
var config;
@@ -428,8 +479,12 @@
});
}
fetch('api.config.json').catch(function() { return { json: function() { return Promise.resolve({ apiBase: '' }); }; }; })
.then(function(r) { return typeof r.json === 'function' ? r.json() : Promise.resolve({ apiBase: '' }); })
fetch('api.config.json')
.then(function(r) {
if (!r.ok) return Promise.resolve({ apiBase: '' });
return typeof r.json === 'function' ? r.json().catch(function() { return { apiBase: '' }; }) : Promise.resolve({ apiBase: '' });
})
.catch(function() { return Promise.resolve({ apiBase: '' }); })
.then(function(apiConfig) {
API = (apiConfig.apiBase || '').replace(/\/$/, '') + '/api';
var xhr = new XMLHttpRequest();

View File

@@ -52,7 +52,7 @@ p;M.style.display="none";B("error",a)}function ja(a){var b=Q(a);fa.style.left=b.
Q(a);if(b.hotSpotDebug){var n=ta(a);console.log("Pitch: "+n[0]+", Yaw: "+n[1]+", Center Pitch: "+b.pitch+", Center Yaw: "+b.yaw+", HFOV: "+b.hfov)}t();Da();b.roll=0;w.hfov=0;ha=!0;N=Date.now();xa=f.x;ya=f.y;Oa=b.yaw;Pa=b.pitch;J.classList.add("pnlm-grabbing");J.classList.remove("pnlm-grab");B("mousedown",a);G()}}function Ja(a){b.minHfov===b.hfov?da.setHfov(ra,1E3):(a=ta(a),da.lookAt(a[0],a[1],b.minHfov,1E3))}function ta(a){var f=Q(a);a=C.getCanvas();var n=a.clientWidth,c=a.clientHeight;a=f.x/n*2-
1;var c=(1-f.y/c*2)*c/n,d=1/Math.tan(b.hfov*Math.PI/360),e=Math.sin(b.pitch*Math.PI/180),g=Math.cos(b.pitch*Math.PI/180),f=d*g-c*e,n=Math.sqrt(a*a+f*f),c=180*Math.atan((c*g+d*e)/n)/Math.PI;a=180*Math.atan2(a/n,f/n)/Math.PI+b.yaw;-180>a&&(a+=360);180<a&&(a-=360);return[c,a]}function ua(a){if(ha&&H){N=Date.now();var f=C.getCanvas(),n=f.clientWidth,f=f.clientHeight;a=Q(a);var c=180*(Math.atan(xa/n*2-1)-Math.atan(a.x/n*2-1))/Math.PI*b.hfov/90+Oa;w.yaw=(c-b.yaw)%360*0.2;b.yaw=c;n=360*Math.atan(Math.tan(b.hfov/
360*Math.PI)*f/n)/Math.PI;n=180*(Math.atan(a.y/f*2-1)-Math.atan(ya/f*2-1))/Math.PI*n/90+Pa;w.pitch=0.2*(n-b.pitch);b.pitch=n}}function ma(a){ha&&(ha=!1,15<Date.now()-N&&(w.pitch=w.yaw=0),J.classList.add("pnlm-grab"),J.classList.remove("pnlm-grabbing"),N=Date.now(),B("mouseup",a))}function Ka(a){if(H&&b.draggable){t();Da();b.roll=0;w.hfov=0;var f=Q(a.targetTouches[0]);xa=f.x;ya=f.y;if(2==a.targetTouches.length){var n=Q(a.targetTouches[1]);xa+=0.5*(n.x-f.x);ya+=0.5*(n.y-f.y);Ha=Math.sqrt((f.x-n.x)*
(f.x-n.x)+(f.y-n.y)*(f.y-n.y))}ha=!0;N=Date.now();Oa=b.yaw;Pa=b.pitch;B("touchstart",a);G()}}function la(a){if(b.draggable&&(a.preventDefault(),H&&(N=Date.now()),ha&&H)){var f=Q(a.targetTouches[0]),n=f.x,c=f.y;2==a.targetTouches.length&&-1!=Ha&&(a=Q(a.targetTouches[1]),n+=0.5*(a.x-f.x),c+=0.5*(a.y-f.y),f=Math.sqrt((f.x-a.x)*(f.x-a.x)+(f.y-a.y)*(f.y-a.y)),x(b.hfov+0.1*(Ha-f)),Ha=f);f=b.hfov/360*b.touchPanSpeedCoeffFactor;n=(xa-n)*f+Oa;w.yaw=(n-b.yaw)%360*0.2;b.yaw=n;c=(c-ya)*f+Pa;w.pitch=0.2*(c-b.pitch);
(f.x-n.x)+(f.y-n.y)*(f.y-n.y))}ha=!0;N=Date.now();Oa=b.yaw;Pa=b.pitch;B("touchstart",a);G()}}function la(a){if(b.draggable&&(a.preventDefault(),H&&(N=Date.now()),ha&&H)){var f=Q(a.targetTouches[0]),n=f.x,c=f.y;2==a.targetTouches.length&&-1!=Ha&&(a=Q(a.targetTouches[1]),n+=0.5*(a.x-f.x),c+=0.5*(a.y-f.y),f=Math.sqrt((f.x-a.x)*(f.x-a.x)+(f.y-a.y)*(f.y-a.y)),x(b.hfov+0.22*(Ha-f)),Ha=f);f=b.hfov/360*b.touchPanSpeedCoeffFactor;n=(xa-n)*f+Oa;w.yaw=(n-b.yaw)%360*0.2;b.yaw=n;c=(c-ya)*f+Pa;w.pitch=0.2*(c-b.pitch);
b.pitch=c}}function Ea(){ha=!1;150<Date.now()-N&&(w.pitch=w.yaw=0);Ha=-1;N=Date.now();B("touchend",event)}function A(a){"touch"==a.pointerType&&H&&b.draggable&&(ia.push(a.pointerId),za.push({clientX:a.clientX,clientY:a.clientY}),a.targetTouches=za,Ka(a),a.preventDefault())}function c(a){if("touch"==a.pointerType&&b.draggable)for(var f=0;f<ia.length;f++)if(a.pointerId==ia[f]){za[f].clientX=a.clientX;za[f].clientY=a.clientY;a.targetTouches=za;la(a);a.preventDefault();break}}function a(a){if("touch"==
a.pointerType){for(var b=!1,n=0;n<ia.length;n++)a.pointerId==ia[n]&&(ia[n]=p),ia[n]&&(b=!0);b||(ia=[],za=[],Ea());a.preventDefault()}}function U(a){H&&("fullscreenonly"!=b.mouseZoom||Aa)&&(a.preventDefault(),t(),N=Date.now(),a.wheelDeltaY?(x(b.hfov-0.05*a.wheelDeltaY),w.hfov=0>a.wheelDelta?1:-1):a.wheelDelta?(x(b.hfov-0.05*a.wheelDelta),w.hfov=0>a.wheelDelta?1:-1):a.detail&&(x(b.hfov+1.5*a.detail),w.hfov=0<a.detail?1:-1),G())}function V(a){t();N=Date.now();Da();b.roll=0;var f=a.which||a.keycode;0>
b.capturedKeyNumbers.indexOf(f)||(a.preventDefault(),27==f?Aa&&h():wa(f,!0))}function $(){for(var a=0;10>a;a++)r[a]=!1}function R(a){var f=a.which||a.keycode;0>b.capturedKeyNumbers.indexOf(f)||(a.preventDefault(),wa(f,!1))}function wa(a,b){var n=!1;switch(a){case 109:case 189:case 17:case 173:r[0]!=b&&(n=!0);r[0]=b;break;case 107:case 187:case 16:case 61:r[1]!=b&&(n=!0);r[1]=b;break;case 38:r[2]!=b&&(n=!0);r[2]=b;break;case 87:r[6]!=b&&(n=!0);r[6]=b;break;case 40:r[3]!=b&&(n=!0);r[3]=b;break;case 83:r[7]!=

117
nginx.config Normal file
View File

@@ -0,0 +1,117 @@
server
{
listen 80;
listen 443 ssl http2 ;
server_name view.airtep.com;
index index.php index.html index.htm default.php default.htm default.html;
root /www/wwwroot/view.airtep.com/dist/;
#CERT-APPLY-CHECK--START
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
include /www/server/panel/vhost/nginx/well-known/view.airtep.com.conf;
#CERT-APPLY-CHECK--END
include /www/server/panel/vhost/nginx/extension/view.airtep.com/*.conf;
#SSL-START SSL相关配置请勿删除或修改下一行带注释的404规则
#error_page 404/404.html;
ssl_certificate /www/server/panel/vhost/cert/view.airtep.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/view.airtep.com/privkey.pem;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
#SSL-END
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;
#error_page 502 /502.html;
#ERROR-PAGE-END
#PHP-INFO-START PHP引用配置可以注释或修改
include enable-php-00.conf;
#PHP-INFO-END
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
include /www/server/panel/vhost/rewrite/view.airtep.com.conf;
#REWRITE-END
# 禁止访问的敏感文件
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
{
return 404;
}
# 禁止访问的敏感目录
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
return 404;
}
#一键申请SSL证书验证目录相关设置
location ~ \.well-known{
allow all;
}
location / {
try_files $uri $uri/ /index.html;
}
# 将 /api 转发到本机 Node 后端(端口与 start-api.sh 一致)
location /api {
proxy_pass http://127.0.0.1:5599/;
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;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$
{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}
location ~ .*\.(js|css)?$
{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
# location /api/ {
# proxy_pass http://localhost:3003/api/;
# }
# 前端 SPA/edit、/db 等路由都返回 index.html
location = /edit {
try_files /index.html =404;
}
location /ws {
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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_read_timeout 86400;
}
access_log /www/wwwlogs/view.airtep.com.log;
error_log /www/wwwlogs/view.airtep.com.error.log;
}

View File

@@ -52,7 +52,7 @@ p;M.style.display="none";B("error",a)}function ja(a){var b=Q(a);fa.style.left=b.
Q(a);if(b.hotSpotDebug){var n=ta(a);console.log("Pitch: "+n[0]+", Yaw: "+n[1]+", Center Pitch: "+b.pitch+", Center Yaw: "+b.yaw+", HFOV: "+b.hfov)}t();Da();b.roll=0;w.hfov=0;ha=!0;N=Date.now();xa=f.x;ya=f.y;Oa=b.yaw;Pa=b.pitch;J.classList.add("pnlm-grabbing");J.classList.remove("pnlm-grab");B("mousedown",a);G()}}function Ja(a){b.minHfov===b.hfov?da.setHfov(ra,1E3):(a=ta(a),da.lookAt(a[0],a[1],b.minHfov,1E3))}function ta(a){var f=Q(a);a=C.getCanvas();var n=a.clientWidth,c=a.clientHeight;a=f.x/n*2-
1;var c=(1-f.y/c*2)*c/n,d=1/Math.tan(b.hfov*Math.PI/360),e=Math.sin(b.pitch*Math.PI/180),g=Math.cos(b.pitch*Math.PI/180),f=d*g-c*e,n=Math.sqrt(a*a+f*f),c=180*Math.atan((c*g+d*e)/n)/Math.PI;a=180*Math.atan2(a/n,f/n)/Math.PI+b.yaw;-180>a&&(a+=360);180<a&&(a-=360);return[c,a]}function ua(a){if(ha&&H){N=Date.now();var f=C.getCanvas(),n=f.clientWidth,f=f.clientHeight;a=Q(a);var c=180*(Math.atan(xa/n*2-1)-Math.atan(a.x/n*2-1))/Math.PI*b.hfov/90+Oa;w.yaw=(c-b.yaw)%360*0.2;b.yaw=c;n=360*Math.atan(Math.tan(b.hfov/
360*Math.PI)*f/n)/Math.PI;n=180*(Math.atan(a.y/f*2-1)-Math.atan(ya/f*2-1))/Math.PI*n/90+Pa;w.pitch=0.2*(n-b.pitch);b.pitch=n}}function ma(a){ha&&(ha=!1,15<Date.now()-N&&(w.pitch=w.yaw=0),J.classList.add("pnlm-grab"),J.classList.remove("pnlm-grabbing"),N=Date.now(),B("mouseup",a))}function Ka(a){if(H&&b.draggable){t();Da();b.roll=0;w.hfov=0;var f=Q(a.targetTouches[0]);xa=f.x;ya=f.y;if(2==a.targetTouches.length){var n=Q(a.targetTouches[1]);xa+=0.5*(n.x-f.x);ya+=0.5*(n.y-f.y);Ha=Math.sqrt((f.x-n.x)*
(f.x-n.x)+(f.y-n.y)*(f.y-n.y))}ha=!0;N=Date.now();Oa=b.yaw;Pa=b.pitch;B("touchstart",a);G()}}function la(a){if(b.draggable&&(a.preventDefault(),H&&(N=Date.now()),ha&&H)){var f=Q(a.targetTouches[0]),n=f.x,c=f.y;2==a.targetTouches.length&&-1!=Ha&&(a=Q(a.targetTouches[1]),n+=0.5*(a.x-f.x),c+=0.5*(a.y-f.y),f=Math.sqrt((f.x-a.x)*(f.x-a.x)+(f.y-a.y)*(f.y-a.y)),x(b.hfov+0.1*(Ha-f)),Ha=f);f=b.hfov/360*b.touchPanSpeedCoeffFactor;n=(xa-n)*f+Oa;w.yaw=(n-b.yaw)%360*0.2;b.yaw=n;c=(c-ya)*f+Pa;w.pitch=0.2*(c-b.pitch);
(f.x-n.x)+(f.y-n.y)*(f.y-n.y))}ha=!0;N=Date.now();Oa=b.yaw;Pa=b.pitch;B("touchstart",a);G()}}function la(a){if(b.draggable&&(a.preventDefault(),H&&(N=Date.now()),ha&&H)){var f=Q(a.targetTouches[0]),n=f.x,c=f.y;2==a.targetTouches.length&&-1!=Ha&&(a=Q(a.targetTouches[1]),n+=0.5*(a.x-f.x),c+=0.5*(a.y-f.y),f=Math.sqrt((f.x-a.x)*(f.x-a.x)+(f.y-a.y)*(f.y-a.y)),x(b.hfov+0.22*(Ha-f)),Ha=f);f=b.hfov/360*b.touchPanSpeedCoeffFactor;n=(xa-n)*f+Oa;w.yaw=(n-b.yaw)%360*0.2;b.yaw=n;c=(c-ya)*f+Pa;w.pitch=0.2*(c-b.pitch);
b.pitch=c}}function Ea(){ha=!1;150<Date.now()-N&&(w.pitch=w.yaw=0);Ha=-1;N=Date.now();B("touchend",event)}function A(a){"touch"==a.pointerType&&H&&b.draggable&&(ia.push(a.pointerId),za.push({clientX:a.clientX,clientY:a.clientY}),a.targetTouches=za,Ka(a),a.preventDefault())}function c(a){if("touch"==a.pointerType&&b.draggable)for(var f=0;f<ia.length;f++)if(a.pointerId==ia[f]){za[f].clientX=a.clientX;za[f].clientY=a.clientY;a.targetTouches=za;la(a);a.preventDefault();break}}function a(a){if("touch"==
a.pointerType){for(var b=!1,n=0;n<ia.length;n++)a.pointerId==ia[n]&&(ia[n]=p),ia[n]&&(b=!0);b||(ia=[],za=[],Ea());a.preventDefault()}}function U(a){H&&("fullscreenonly"!=b.mouseZoom||Aa)&&(a.preventDefault(),t(),N=Date.now(),a.wheelDeltaY?(x(b.hfov-0.05*a.wheelDeltaY),w.hfov=0>a.wheelDelta?1:-1):a.wheelDelta?(x(b.hfov-0.05*a.wheelDelta),w.hfov=0>a.wheelDelta?1:-1):a.detail&&(x(b.hfov+1.5*a.detail),w.hfov=0<a.detail?1:-1),G())}function V(a){t();N=Date.now();Da();b.roll=0;var f=a.which||a.keycode;0>
b.capturedKeyNumbers.indexOf(f)||(a.preventDefault(),27==f?Aa&&h():wa(f,!0))}function $(){for(var a=0;10>a;a++)r[a]=!1}function R(a){var f=a.which||a.keycode;0>b.capturedKeyNumbers.indexOf(f)||(a.preventDefault(),wa(f,!1))}function wa(a,b){var n=!1;switch(a){case 109:case 189:case 17:case 173:r[0]!=b&&(n=!0);r[0]=b;break;case 107:case 187:case 16:case 61:r[1]!=b&&(n=!0);r[1]=b;break;case 38:r[2]!=b&&(n=!0);r[2]=b;break;case 87:r[6]!=b&&(n=!0);r[6]=b;break;case 40:r[3]!=b&&(n=!0);r[3]=b;break;case 83:r[7]!=

53
run.sh
View File

@@ -1,12 +1,16 @@
#!/usr/bin/env bash
# 部署流程:拉代码 → 安装依赖 → 构建前端到 dist/ → 后台启动后端 API5599
# 部署流程:拉代码 → 安装依赖 → 构建前端到 dist/ →(可选)同步到 Nginx root → 后台启动 API5599
# 若 Nginx 未反代 /api请先设置 API_BASE_URL 再执行,例如:
# export API_BASE_URL="http://你的域名或IP:5599"
# ./run.sh
# 使用 pm2 保活推荐export USE_PM2=1 && ./run.sh
# 若项目在子目录、Nginx root 指向固定目录,构建后同步到该目录,例如:
# export DEPLOY_DIST=/www/wwwroot/view.airtep.com/dist
# ./run.sh
set -e
cd "$(dirname "$0")"
ROOT="$(pwd)"
API_PORT="${PORT:-5599}"
export PORT="$API_PORT"
@@ -21,21 +25,52 @@ fi
node build.js
# 后台启动 API优先 pm2否则 nohup
# 若设置了 DEPLOY_DIST与 Nginx root 一致),将 dist/ 同步到该目录以便访问
if [ -n "$DEPLOY_DIST" ]; then
mkdir -p "$DEPLOY_DIST"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete dist/ "$DEPLOY_DIST/"
else
(cd dist && find . -mindepth 1 -maxdepth 1 -exec cp -r {} "$DEPLOY_DIST/" \;)
fi
echo "[run.sh] 已同步 dist/ 到 $DEPLOY_DIST"
fi
# 释放端口:避免 EADDRINUSE上次进程未退出或多次执行 run.sh
_kill_port() {
if command -v lsof >/dev/null 2>&1; then
pids=$(lsof -t -i ":$API_PORT" 2>/dev/null) || true
if [ -n "$pids" ]; then
echo "[run.sh] 停止占用端口 ${API_PORT} 的进程: $pids"
echo "$pids" | xargs kill 2>/dev/null || true
sleep 1
fi
elif command -v fuser >/dev/null 2>&1; then
if fuser -n tcp "$API_PORT" >/dev/null 2>&1; then
echo "[run.sh] 停止占用端口 ${API_PORT} 的进程..."
fuser -k "$API_PORT/tcp" 2>/dev/null || true
sleep 1
fi
fi
}
_kill_port
# 后台启动 API优先 pm2否则 nohupROOT 与 PORT 已在上方定义)
if [ -n "$USE_PM2" ] && command -v pm2 >/dev/null 2>&1; then
echo "[run.sh] 使用 pm2 启动 API端口 ${API_PORT}..."
(cd server && npm install --no-audit --no-fund --silent && pm2 delete view-airtep-api 2>/dev/null; true)
(cd server && pm2 start index.js --name view-airtep-api)
(cd "$ROOT/server" && npm install --no-audit --no-fund --silent && pm2 delete view-airtep-api 2>/dev/null; true)
(cd "$ROOT/server" && PORT="$API_PORT" pm2 start index.js --name view-airtep-api)
pm2 save 2>/dev/null || true
echo "[run.sh] API 已由 pm2 托管。查看: pm2 list / pm2 logs view-airtep-api"
elif command -v nohup >/dev/null 2>&1; then
echo "[run.sh] 使用 nohup 后台启动 API端口 ${API_PORT}..."
mkdir -p logs
nohup bash start-api.sh >> logs/api.log 2>&1 &
echo "[run.sh] API 已在后台运行。查看日志: tail -f logs/api.log"
mkdir -p "$ROOT/logs"
nohup bash "$ROOT/start-api.sh" >> "$ROOT/logs/api.log" 2>&1 &
echo "[run.sh] API 已在后台运行。查看日志: tail -f $ROOT/logs/api.log"
else
echo "[run.sh] 前台启动 APICtrl+C 会停止服务)..."
exec bash start-api.sh
exec bash "$ROOT/start-api.sh"
fi
echo "[run.sh] 部署完成。前端静态在 dist/API 端口 ${API_PORT}检查: curl -s http://127.0.0.1:${API_PORT}/api/config"
echo "[run.sh] 部署完成。前端静态在 dist/API 端口 ${API_PORT}"
echo "[run.sh] 检查服务: curl -s http://127.0.0.1:${API_PORT}/config 或 ./check-service.sh"

View File

@@ -10,6 +10,7 @@
var viewerId = null;
var statsInterval = null;
var danmakuLastId = 0;
var lastStats = { viewCount: 0, likeCount: 0, shareCount: 0, commentCount: 0, watchingNow: 0 };
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
@@ -48,22 +49,53 @@
if (container) container.innerHTML = '';
}
function isMobileView() {
return (
(typeof window.matchMedia !== 'undefined' &&
window.matchMedia('(max-width: 768px)').matches) ||
typeof window.orientation !== 'undefined' ||
('ontouchstart' in window && window.innerWidth <= 1024)
);
}
function buildViewer(config) {
destroyViewer();
hideError();
if (container) container.style.display = 'block';
config.autoLoad = config.autoLoad !== false;
config.hfov = config.hfov || 100;
config.minHfov = config.minHfov != null ? config.minHfov : 50;
config.maxHfov = config.maxHfov != null ? config.maxHfov : 120;
var mobile = isMobileView();
var targetHfov;
if (mobile) {
targetHfov = config.hfov != null ? Math.min(config.hfov, 75) : 72;
config.minHfov = config.minHfov != null ? config.minHfov : 28;
config.maxHfov = config.maxHfov != null ? Math.min(config.maxHfov, 108) : 105;
config.hfov = config.maxHfov;
} else {
targetHfov = config.hfov || 100;
config.minHfov = config.minHfov != null ? config.minHfov : 50;
config.maxHfov = config.maxHfov != null ? config.maxHfov : 120;
config.hfov = config.maxHfov;
}
config.showZoomCtrl = false;
config.compass = false;
config.showFullscreenCtrl = false;
config.draggable = true;
config.friction = 0.06;
config.touchPanSpeedCoeffFactor = 1.2;
try {
currentViewer = pannellum.viewer('panorama', config);
var fisheyeDone = false;
function runFisheyeEnter() {
if (fisheyeDone || !currentViewer) return;
fisheyeDone = true;
if (currentViewer.getHfov && currentViewer.getHfov() === config.maxHfov) {
currentViewer.setHfov(targetHfov, 1500);
}
}
currentViewer.on('error', function (err) {
showError('全景图加载失败:' + (err && err.message ? err.message : '未知错误'));
});
currentViewer.on('load', runFisheyeEnter);
setTimeout(runFisheyeEnter, 500);
} catch (e) {
showError('创建查看器失败:' + (e.message || e));
}
@@ -71,16 +103,21 @@
function updateStatsUI(stats) {
if (!stats) return;
var keys = ['viewCount', 'likeCount', 'shareCount', 'commentCount', 'watchingNow'];
keys.forEach(function (k) {
if (stats[k] !== undefined) lastStats[k] = stats[k];
});
var cur = lastStats;
var w = document.getElementById('watchingNow');
if (w) w.textContent = (stats.watchingNow || 0) + ' 人在看';
if (w) w.textContent = (cur.watchingNow || 0) + ' 人在看';
var v = document.getElementById('viewCount');
if (v) v.textContent = '共 ' + formatNum(stats.viewCount || 0) + ' 次播放';
if (v) v.textContent = '共 ' + formatNum(cur.viewCount || 0) + ' 次播放';
var l = document.getElementById('likeCount');
if (l) l.textContent = formatNum(stats.likeCount || 0);
var s = document.getElementById('shareCount');
if (s) s.textContent = formatNum(stats.shareCount || 0);
if (l) l.textContent = formatNum(cur.likeCount || 0);
var shareEl = document.getElementById('shareCount');
if (shareEl) shareEl.textContent = formatNum(cur.shareCount || 0);
var c = document.getElementById('commentCount');
if (c) c.textContent = formatNum(stats.commentCount || 0);
if (c) c.textContent = formatNum(cur.commentCount || 0);
}
function formatNum(n) {
n = Number(n);

View File

@@ -8,6 +8,22 @@ cd "$(dirname "$0")/server"
PORT="${PORT:-5599}"
export PORT
# 释放端口,避免 EADDRINUSE上次未退出或多次启动
if command -v lsof >/dev/null 2>&1; then
pids=$(lsof -t -i ":$PORT" 2>/dev/null) || true
if [ -n "$pids" ]; then
echo "[720yun-offline-api] 停止占用端口 ${PORT} 的进程: $pids"
echo "$pids" | xargs kill 2>/dev/null || true
sleep 1
fi
elif command -v fuser >/dev/null 2>&1; then
if fuser -n tcp "$PORT" >/dev/null 2>&1; then
echo "[720yun-offline-api] 停止占用端口 ${PORT} 的进程..."
fuser -k "$PORT/tcp" 2>/dev/null || true
sleep 1
fi
fi
echo "[720yun-offline-api] 安装依赖..."
npm install --no-audit --no-fund