Compare commits
13 Commits
7d7f13d009
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b36978ce | ||
|
|
788d09b24b | ||
|
|
d6426d338e | ||
|
|
a62b203100 | ||
|
|
a403c83fe4 | ||
|
|
a6d515b6c5 | ||
|
|
e79c84efaf | ||
|
|
8981e08d0b | ||
|
|
37203a1964 | ||
|
|
b05ebb80fa | ||
|
|
2dd1117e51 | ||
|
|
06c0e36d92 | ||
|
|
890dea096b |
29
build.js
29
build.js
@@ -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
95
check-nginx-and-api.sh
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 检查 Nginx 反代配置与本地 API(5599)是否正常
|
||||
# 在服务器上执行: ./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
|
||||
@@ -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,仅检查了端口监听。"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"image/mobile_u.jpg",
|
||||
"image/mobile_d.jpg"
|
||||
],
|
||||
"title": "四川省广兴镇蔡家庵 全景图(纪念版)",
|
||||
"title": "四川省广兴镇蔡家11&2组 全景图(纪念版)",
|
||||
"autoLoad": true,
|
||||
"showControls": true,
|
||||
"hfov": 100,
|
||||
|
||||
@@ -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;
|
||||
|
||||
102
docs/nginx-view.airtep.com-完整示例.conf
Normal file
102
docs/nginx-view.airtep.com-完整示例.conf
Normal 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;
|
||||
}
|
||||
19
docs/nginx-view.airtep.com-必加.conf
Normal file
19
docs/nginx-view.airtep.com-必加.conf
Normal 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;
|
||||
# }
|
||||
@@ -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;
|
||||
|
||||
15
docs/全景视角与移动端缩放说明.md
Normal file
15
docs/全景视角与移动端缩放说明.md
Normal 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` 里按需修改。
|
||||
86
docs/前端与接口关系说明.md
Normal file
86
docs/前端与接口关系说明.md
Normal 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**,请求被转到错误后端。 |
|
||||
@@ -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 服务并正常运行。
|
||||
|
||||
89
index.html
89
index.html
@@ -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();
|
||||
|
||||
@@ -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
117
nginx.config
Normal 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;
|
||||
}
|
||||
@@ -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
53
run.sh
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# 部署流程:拉代码 → 安装依赖 → 构建前端到 dist/ → 后台启动后端 API(5599)
|
||||
# 部署流程:拉代码 → 安装依赖 → 构建前端到 dist/ →(可选)同步到 Nginx root → 后台启动 API(5599)
|
||||
# 若 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,否则 nohup(ROOT 与 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] 前台启动 API(Ctrl+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"
|
||||
55
src/main.js
55
src/main.js
@@ -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);
|
||||
|
||||
16
start-api.sh
16
start-api.sh
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user