feat: 初始化项目
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ panorama/*.webp
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
server/data/
|
||||
|
||||
31
README.md
31
README.md
@@ -6,24 +6,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
## 项目结构(前后端分离)
|
||||
|
||||
```
|
||||
720yun-offline/
|
||||
├── index.html # 本地全景查看页(Pannellum)
|
||||
├── config.json # 全景配置(默认使用 image/ 六面图)
|
||||
├── lib/
|
||||
│ ├── pannellum.js
|
||||
│ └── pannellum.css
|
||||
├── image/ # 六面体全景图(mobile_f/r/b/l/u/d.jpg),默认展示来源
|
||||
├── panorama/ # 单张全景图(可选)
|
||||
├── server.js # Node 静态 + API 服务(统计、留言、弹幕)
|
||||
├── db.js # SQLite 封装(data/pano.db)
|
||||
├── data/ # 数据库目录(自动创建,已 gitignore)
|
||||
├── build.js # 构建脚本,产出 dist/ 便于部署
|
||||
├── package.json # Node 脚本:start / build / preview
|
||||
├── fetch_720yun.py # 自动抓取脚本(若页面有直出 URL 则可用)
|
||||
├── parse_720yun_doc.py # 从 text.md 解析 URL,--download 下载到 image/
|
||||
├── index.html # 前端页面(Pannellum + 统计/弹幕)
|
||||
├── config.json # 全景配置(六面图、简介等)
|
||||
├── api.config.json # 前端用:API 根地址,空为同源
|
||||
├── lib/ # Pannellum
|
||||
├── image/ # 六面体全景图
|
||||
├── server.js # 本地联合模式:静态 + 挂载 server/ API
|
||||
├── build.js # 构建前端静态到 dist/
|
||||
├── package.json # 根目录:start / build / preview
|
||||
├── server/ # 后端(可单独部署)
|
||||
│ ├── server.js # Express API 路由
|
||||
│ ├── db.js # SQLite(统计、留言、settings 弹幕开关)
|
||||
│ ├── index.js # 单独启动 API:node index.js
|
||||
│ ├── package.json # 后端依赖
|
||||
│ └── data/ # pano.db(自动创建)
|
||||
├── docs/部署说明.md # 前后端分离部署与弹幕配置
|
||||
└── README.md
|
||||
```
|
||||
|
||||
|
||||
3
api.config.json
Normal file
3
api.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiBase": ""
|
||||
}
|
||||
3
build.js
3
build.js
@@ -31,10 +31,11 @@ function main() {
|
||||
|
||||
copyFile(path.join(ROOT, 'index.html'), path.join(DIST, 'index.html'));
|
||||
copyFile(path.join(ROOT, 'config.json'), path.join(DIST, 'config.json'));
|
||||
copyFile(path.join(ROOT, 'api.config.json'), path.join(DIST, 'api.config.json'));
|
||||
copyDir(path.join(ROOT, 'lib'), path.join(DIST, 'lib'));
|
||||
copyDir(path.join(ROOT, 'image'), path.join(DIST, 'image'));
|
||||
|
||||
console.log('已构建到 dist/,可直接部署 dist 目录。');
|
||||
console.log('已构建到 dist/(前端静态),可直接部署。');
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"image/mobile_u.jpg",
|
||||
"image/mobile_d.jpg"
|
||||
],
|
||||
"title": "广兴镇蔡家庵纪念 全景离线",
|
||||
"title": "四川省广兴镇蔡家庵 全景图(纪念版)",
|
||||
"autoLoad": true,
|
||||
"showControls": true,
|
||||
"hfov": 100,
|
||||
@@ -16,6 +16,7 @@
|
||||
"maxHfov": 120,
|
||||
"authorName": "创作者:广兴镇蔡家11组原住民",
|
||||
"authorUrl": "",
|
||||
"intro": "这里曾是一座依山而居、世代相传的原始村落。青瓦土墙、石板小路、院落炊烟,记录着村民们质朴而悠长的生活记忆。随着时代的发展,国家重大交通工程 西渝高铁 的线路从这里经过,为区域带来新的机遇与活力。为了配合建设,村民们整体搬迁至新的安置点,开启了更加便利、现代的生活。如今,这片旧址被完整保留下来,以最原始的村落风貌呈现在人们面前。通过全景影像,曾经的院落布局、街巷肌理与自然环境得以真实记录,也让人们得以重新走进这段乡村记忆。这幅全景图不仅是一份空间记录,更是一段关于 时代变迁、乡愁记忆与发展轨迹 的见证。旧村虽迁,但生活的印记与文化的脉络,依然在这片土地上静静延续。",
|
||||
"viewCount": 0,
|
||||
"watchingNow": 0
|
||||
}
|
||||
|
||||
5
docs/readm.md
Normal file
5
docs/readm.md
Normal file
@@ -0,0 +1,5 @@
|
||||
这里曾是一座依山而居、世代相传的原始村落。青瓦土墙、石板小路、院落炊烟,记录着村民们质朴而悠长的生活记忆。随着时代的发展,国家重大交通工程 西渝高铁 的线路从这里经过,为区域带来新的机遇与活力。为了配合建设,村民们整体搬迁至新的安置点,开启了更加便利、现代的生活。
|
||||
|
||||
如今,这片旧址被完整保留下来,以最原始的村落风貌呈现在人们面前。通过全景影像,曾经的院落布局、街巷肌理与自然环境得以真实记录,也让人们得以重新走进这段乡村记忆。
|
||||
|
||||
这幅全景图不仅是一份空间记录,更是一段关于 时代变迁、乡愁记忆与发展轨迹 的见证。旧村虽迁,但生活的印记与文化的脉络,依然在这片土地上静静延续。
|
||||
101
docs/部署说明.md
Normal file
101
docs/部署说明.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 前后端分离部署说明
|
||||
|
||||
项目支持**前后端分离**部署:前端为纯静态资源,后端为独立 Node API,便于分别部署到静态主机与 API 服务器。
|
||||
|
||||
---
|
||||
|
||||
## 一、目录与职责
|
||||
|
||||
| 类型 | 目录/文件 | 说明 |
|
||||
|------|-----------|------|
|
||||
| **前端(静态)** | `index.html`, `config.json`, `api.config.json`, `lib/`, `image/` | 可部署到 Nginx、OSS、Vercel、GitHub Pages 等 |
|
||||
| **后端(API)** | `server/`(含 `server.js`, `db.js`, `index.js`, `package.json`) | 单独部署到 Node 主机,仅提供 `/api/*` 接口 |
|
||||
|
||||
---
|
||||
|
||||
## 二、前端部署
|
||||
|
||||
1. **构建静态包**(在项目根目录):
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
产出目录 `dist/`,内含:`index.html`, `config.json`, `api.config.json`, `lib/`, `image/`。
|
||||
|
||||
2. **配置 API 地址**:
|
||||
将 `dist/` 部署到你的静态站点后,修改 **`dist/api.config.json`**(或部署前在根目录改好再 build):
|
||||
```json
|
||||
{
|
||||
"apiBase": "https://你的API域名"
|
||||
}
|
||||
```
|
||||
- 不填或空字符串 `""` 表示与当前页面同源(例如 Nginx 反向代理了 `/api` 到后端时可用 `""`)。
|
||||
- 前后端不同域时填后端根地址,例如 `https://api.example.com`(不要以 `/` 结尾)。
|
||||
前端会请求 `apiBase + '/api/config'`、`apiBase + '/api/stats'` 等。
|
||||
|
||||
3. **上传**:将 `dist/` 内所有文件上传到静态主机或 CDN。
|
||||
|
||||
---
|
||||
|
||||
## 三、后端部署
|
||||
|
||||
1. **只部署 API**:进入后端目录并安装依赖、启动:
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
默认端口 3000,可通过环境变量 `PORT` 修改。
|
||||
数据库文件为 `server/data/pano.db`(首次运行自动创建)。
|
||||
|
||||
2. **生产环境建议**:
|
||||
- 使用 pm2、systemd 等保活。
|
||||
- 如需限制跨域,设置环境变量 `CORS_ORIGIN` 为前端域名,例如:
|
||||
`CORS_ORIGIN=https://你的前端域名`
|
||||
|
||||
---
|
||||
|
||||
## 四、弹幕配置(后端)
|
||||
|
||||
弹幕**默认不显示**,由后端配置控制。
|
||||
|
||||
- 接口:**GET /api/config** 返回示例:
|
||||
```json
|
||||
{
|
||||
"danmakuEnabled": false,
|
||||
"danmakuPosition": "top"
|
||||
}
|
||||
```
|
||||
- 数据来源:SQLite 表 `settings`(在 `server/data/pano.db` 所在库):
|
||||
- `danmaku_enabled`:`1` 开启弹幕,`0` 关闭(默认)。
|
||||
- `danmaku_position`:弹幕区域位置,目前仅使用 `top`(顶部)。
|
||||
|
||||
**开启弹幕**:在部署后端的机器上执行(或通过自建管理接口写入):
|
||||
|
||||
```bash
|
||||
cd server
|
||||
node -e "
|
||||
const db = require('better-sqlite3')('data/pano.db');
|
||||
db.prepare(\"INSERT OR REPLACE INTO settings (key, value) VALUES ('danmaku_enabled', '1')\").run();
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
或使用 SQLite 客户端执行:
|
||||
```sql
|
||||
INSERT OR REPLACE INTO settings (key, value) VALUES ('danmaku_enabled', '1');
|
||||
```
|
||||
|
||||
前端会周期性/首次请求 `/api/config`,收到 `danmakuEnabled: true` 后显示顶部弹幕并拉取留言。
|
||||
|
||||
---
|
||||
|
||||
## 五、本地联合运行(不分离)
|
||||
|
||||
在项目根目录:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
会同时提供静态资源与 `/api` 接口(根目录 `server.js` 挂载 `server/` 的 API),适合本地开发。此时 `api.config.json` 的 `apiBase` 保持为 `""` 即可。
|
||||
219
index.html
219
index.html
@@ -2,11 +2,12 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<title>全景查看</title>
|
||||
<link rel="stylesheet" href="lib/pannellum.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
.pano-root { flex: 1 1 0%; position: relative; width: 100%; height: 100%; }
|
||||
#krp { position: absolute; inset: 0; z-index: 0; }
|
||||
#player_krp {
|
||||
@@ -23,14 +24,17 @@
|
||||
}
|
||||
.load-error a { color: #08c; }
|
||||
|
||||
/* 弹幕层 */
|
||||
/* 弹幕层:默认不显示,由后端 GET /api/config 的 danmakuEnabled 控制;位置在顶部 */
|
||||
#danmaku-wrap {
|
||||
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
|
||||
position: absolute; left: 0; top: 0; width: 100%; height: 22%;
|
||||
pointer-events: none; z-index: 5; overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
#danmaku-wrap.danmaku-on { display: block; }
|
||||
.danmaku-line {
|
||||
position: absolute; white-space: nowrap;
|
||||
font-size: 14px; color: #fff; text-shadow: 0 0 2px #000, 0 1px 4px rgba(0,0,0,.8);
|
||||
font-size: clamp(12px, 3.5vw, 15px); color: #fff;
|
||||
text-shadow: 0 0 2px #000, 0 1px 4px rgba(0,0,0,.8);
|
||||
animation: danmaku-scroll 15s linear forwards;
|
||||
}
|
||||
@keyframes danmaku-scroll {
|
||||
@@ -39,44 +43,71 @@
|
||||
}
|
||||
|
||||
/* 顶部区域 */
|
||||
.topWp { position: absolute; left: 0; top: 0; width: 100%; pointer-events: none; z-index: 10; }
|
||||
.topWp { position: absolute; left: 0; top: 0; width: 100%; pointer-events: none; z-index: 10; padding: env(safe-area-inset-top) 0 0; }
|
||||
.topWp > * { pointer-events: auto; }
|
||||
.LeftBtn { position: absolute; left: 0; top: 12px; padding: 0 16px; }
|
||||
.authorPvBox { margin-bottom: 8px; }
|
||||
.Author { color: #fff; text-decoration: none; font-size: 13px; opacity: 1; }
|
||||
.view-stats { color: rgba(255,255,255,.7); font-size: 12px; margin-top: 4px; }
|
||||
.view-stats span { margin-right: 12px; }
|
||||
.LeftBtn {
|
||||
position: absolute; left: 0; top: 12px; padding: 0 12px 0 max(12px, env(safe-area-inset-left));
|
||||
max-width: calc(100vw - 100px);
|
||||
}
|
||||
.authorPvBox { margin-bottom: 6px; }
|
||||
.Author { color: #fff; text-decoration: none; font-size: clamp(12px, 3.2vw, 14px); opacity: 1; }
|
||||
.view-stats {
|
||||
color: rgba(255,255,255,.85); font-size: clamp(11px, 2.8vw, 13px);
|
||||
margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center;
|
||||
}
|
||||
.view-stats .stat-item { white-space: nowrap; }
|
||||
|
||||
.RightBtn { position: absolute; right: 12px; top: 12px; display: flex; gap: 8px; align-items: center; }
|
||||
.RightBtn {
|
||||
position: absolute; right: max(12px, env(safe-area-inset-right)); top: 12px;
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
.RightBtn .btn-wrap {
|
||||
display: flex; flex-direction: column; align-items: center; cursor: pointer;
|
||||
padding: 6px 10px; background: rgba(0,0,0,.3); border-radius: 6px; color: #fff;
|
||||
font-size: 12px; border: none; font-family: inherit;
|
||||
padding: clamp(6px, 2vw, 10px) clamp(8px, 2.5vw, 12px);
|
||||
min-height: 44px; min-width: 44px; box-sizing: border-box;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,.3); border-radius: 8px; color: #fff;
|
||||
font-size: clamp(11px, 2.8vw, 13px); border: none; font-family: inherit;
|
||||
}
|
||||
.RightBtn .btn-wrap:hover { background: rgba(0,0,0,.5); }
|
||||
.RightBtn .btn-wrap svg { width: 24px; height: 24px; margin-bottom: 2px; }
|
||||
|
||||
/* 底部区域 */
|
||||
.bottom { position: absolute; left: 0; bottom: 0; width: 100%; z-index: 10; pointer-events: none; }
|
||||
.bottom { position: absolute; left: 0; bottom: 0; width: 100%; z-index: 10; pointer-events: none; padding: 0 0 env(safe-area-inset-bottom) 0; }
|
||||
.bottom > * { pointer-events: auto; }
|
||||
.bottomWp { display: flex; justify-content: flex-end; align-items: center; padding: 12px 16px; }
|
||||
.bottomWp { display: flex; justify-content: flex-end; align-items: center; padding: 10px max(12px, env(safe-area-inset-right)) 10px 12px; gap: 6px; flex-wrap: wrap; }
|
||||
.bottom_right .CustomButton {
|
||||
display: inline-flex; flex-direction: column; align-items: center; padding: 8px 12px;
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: clamp(8px, 2vw, 12px) clamp(10px, 3vw, 14px);
|
||||
min-height: 44px; min-width: 44px; box-sizing: border-box;
|
||||
background: rgba(0,0,0,.3); border-radius: 8px; color: #fff; cursor: pointer;
|
||||
font-size: 12px; border: 2px solid transparent; margin: 0 4px;
|
||||
font-size: clamp(11px, 2.8vw, 13px); border: 2px solid transparent; margin: 0;
|
||||
}
|
||||
.bottom_right .CustomButton:hover { background: rgba(0,0,0,.5); }
|
||||
.bottom_right .CustomButton.selected { border-color: #fa6400; }
|
||||
.CustomButton .count { font-size: 10px; opacity: .9; margin-top: 2px; }
|
||||
.CustomButton .btn-label { display: block; }
|
||||
.safeHeight { height: env(safe-area-inset-bottom, 0); }
|
||||
|
||||
/* 点赞飘字 */
|
||||
#likeFloatWrap { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
|
||||
.like-float {
|
||||
position: absolute; left: 50%; bottom: clamp(100px, 25vh, 140px);
|
||||
font-size: clamp(22px, 6vw, 30px); font-weight: bold; color: #ff6b6b;
|
||||
text-shadow: 0 0 8px rgba(255,107,107,.8);
|
||||
animation: like-float-up 0.9s ease-out forwards;
|
||||
}
|
||||
@keyframes like-float-up {
|
||||
0% { transform: translate(-50%, 0) scale(0.8); opacity: 1; }
|
||||
100% { transform: translate(-50%, -80px) scale(1.2); opacity: 0; }
|
||||
}
|
||||
|
||||
/* 留言弹窗 */
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box; }
|
||||
.modal-mask.hide { display: none; }
|
||||
.modal-box { background: #333; color: #fff; border-radius: 12px; padding: 20px; min-width: 280px; max-width: 90vw; }
|
||||
.modal-box h3 { margin: 0 0 12px; font-size: 16px; }
|
||||
.modal-box input, .modal-box textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 10px; border: 1px solid #555; border-radius: 6px; background: #222; color: #fff; font-size: 14px; }
|
||||
.modal-box textarea { min-height: 72px; resize: vertical; }
|
||||
.modal-box { background: #333; color: #fff; border-radius: 12px; padding: clamp(16px, 4vw, 20px); width: 100%; max-width: 360px; box-sizing: border-box; }
|
||||
.modal-box h3 { margin: 0 0 12px; font-size: clamp(15px, 4vw, 17px); }
|
||||
.modal-box input, .modal-box textarea { width: 100%; box-sizing: border-box; padding: 12px 14px; margin-bottom: 10px; border: 1px solid #555; border-radius: 8px; background: #222; color: #fff; font-size: 16px; -webkit-appearance: none; }
|
||||
.modal-box textarea { min-height: 80px; resize: vertical; }
|
||||
.modal-box .btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
.modal-box .btns button { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.modal-box .btns .btn-ok { background: #fa6400; color: #fff; }
|
||||
@@ -104,8 +135,11 @@
|
||||
<a class="Author" href="#" target="_blank" rel="noopener" id="authorName">创作者:本地</a>
|
||||
</div>
|
||||
<div class="view-stats">
|
||||
<span id="watchingNow">0 人在看</span>
|
||||
<span id="viewCount">共 0 次播放</span>
|
||||
<span class="stat-item" id="watchingNow">0 人在看</span>
|
||||
<span class="stat-item" id="viewCount">共 0 次播放</span>
|
||||
<span class="stat-item">赞 <span id="likeCount">0</span></span>
|
||||
<span class="stat-item">分享 <span id="shareCount">0</span></span>
|
||||
<span class="stat-item">留言 <span id="commentCount">0</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="RightBtn">
|
||||
@@ -119,10 +153,10 @@
|
||||
<div class="bottom">
|
||||
<div class="bottomWp">
|
||||
<div class="bottom_right">
|
||||
<div class="CustomButton" id="btnIntro" title="简介">简介</div>
|
||||
<div class="CustomButton" id="btnShare" title="分享">分享 <span class="count" id="shareCount">0</span></div>
|
||||
<div class="CustomButton" id="btnLike" title="赞">赞 <span class="count" id="likeCount">0</span></div>
|
||||
<div class="CustomButton" id="btnComment" title="留言">留言</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="safeHeight"></div>
|
||||
@@ -130,10 +164,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="likeFloatWrap" aria-hidden="true"></div>
|
||||
|
||||
<div id="introModal" class="modal-mask hide">
|
||||
<div class="modal-box">
|
||||
<h3>简介</h3>
|
||||
<div id="introContent" style="white-space: pre-wrap; max-height: 60vh; overflow: auto; line-height: 1.5;"></div>
|
||||
<div class="btns" style="margin-top: 12px;">
|
||||
<button type="button" class="btn-ok" id="introModalClose">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="commentModal" class="modal-mask hide">
|
||||
<div class="modal-box">
|
||||
<h3>留言(弹幕)</h3>
|
||||
<input type="text" id="commentNickname" placeholder="昵称(选填)" maxlength="32">
|
||||
<textarea id="commentContent" placeholder="说点什么…" maxlength="200"></textarea>
|
||||
<div class="btns">
|
||||
<button type="button" class="btn-cancel" id="commentCancel">取消</button>
|
||||
@@ -210,11 +255,30 @@
|
||||
var w = document.getElementById('watchingNow');
|
||||
if (w) w.textContent = (stats.watchingNow || 0) + ' 人在看';
|
||||
var v = document.getElementById('viewCount');
|
||||
if (v) v.textContent = '共 ' + (stats.viewCount || 0) + ' 次播放';
|
||||
if (v) v.textContent = '共 ' + formatNum(stats.viewCount || 0) + ' 次播放';
|
||||
var l = document.getElementById('likeCount');
|
||||
if (l) l.textContent = stats.likeCount || 0;
|
||||
if (l) l.textContent = formatNum(stats.likeCount || 0);
|
||||
var s = document.getElementById('shareCount');
|
||||
if (s) s.textContent = stats.shareCount || 0;
|
||||
if (s) s.textContent = formatNum(stats.shareCount || 0);
|
||||
var c = document.getElementById('commentCount');
|
||||
if (c) c.textContent = formatNum(stats.commentCount || 0);
|
||||
}
|
||||
function formatNum(n) {
|
||||
n = Number(n);
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
return String(n);
|
||||
}
|
||||
function showLikeFloat() {
|
||||
var wrap = document.getElementById('likeFloatWrap');
|
||||
if (!wrap) return;
|
||||
var el = document.createElement('span');
|
||||
el.className = 'like-float';
|
||||
el.textContent = '+1';
|
||||
wrap.appendChild(el);
|
||||
setTimeout(function() {
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
}, 950);
|
||||
}
|
||||
|
||||
function fetchStats(cb) {
|
||||
@@ -268,13 +332,13 @@
|
||||
viewerId = null;
|
||||
}
|
||||
|
||||
function addDanmakuLine(text, nickname) {
|
||||
function addDanmakuLine(text) {
|
||||
var wrap = document.getElementById('danmaku-wrap');
|
||||
if (!wrap) return;
|
||||
if (!wrap || !wrap.classList.contains('danmaku-on')) return;
|
||||
var line = document.createElement('div');
|
||||
line.className = 'danmaku-line';
|
||||
line.textContent = (nickname ? nickname + ':' : '') + text;
|
||||
line.style.top = (10 + Math.random() * 75) + '%';
|
||||
line.textContent = text || '';
|
||||
line.style.top = (2 + Math.random() * 16) + '%';
|
||||
line.style.animationDuration = (12 + Math.random() * 8) + 's';
|
||||
wrap.appendChild(line);
|
||||
setTimeout(function() {
|
||||
@@ -294,8 +358,8 @@
|
||||
list.forEach(function(c, i) {
|
||||
var line = document.createElement('div');
|
||||
line.className = 'danmaku-line';
|
||||
line.textContent = (c.nickname || '游客') + ':' + (c.content || '');
|
||||
line.style.top = (10 + (i % 8) * 10 + Math.random() * 5) + '%';
|
||||
line.textContent = c.content || '';
|
||||
line.style.top = (2 + (i % 6) * 3 + Math.random() * 2) + '%';
|
||||
line.style.animationDuration = (14 + (i % 5)) + 's';
|
||||
line.style.animationDelay = (i * 0.8) + 's';
|
||||
wrap.appendChild(line);
|
||||
@@ -306,12 +370,24 @@
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function applyBackendConfig(cfg) {
|
||||
var wrap = document.getElementById('danmaku-wrap');
|
||||
if (!wrap) return;
|
||||
if (cfg && cfg.danmakuEnabled === true) {
|
||||
wrap.classList.add('danmaku-on');
|
||||
loadDanmaku();
|
||||
} else {
|
||||
wrap.classList.remove('danmaku-on');
|
||||
}
|
||||
}
|
||||
|
||||
function updateUIFromConfig(config) {
|
||||
var authorEl = document.getElementById('authorName');
|
||||
if (authorEl && config.authorName) {
|
||||
authorEl.textContent = config.authorName;
|
||||
if (config.authorUrl) authorEl.href = config.authorUrl;
|
||||
}
|
||||
introText = (config.intro != null && config.intro !== '') ? String(config.intro) : '';
|
||||
}
|
||||
|
||||
function loadFromConfig(cb) {
|
||||
@@ -339,17 +415,34 @@
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
loadFromConfig(function(config) {
|
||||
if (!config) {
|
||||
showError('无法加载 config.json 或图片,请通过 http 访问(如 npm start)。');
|
||||
return;
|
||||
}
|
||||
fetchStats();
|
||||
sendView();
|
||||
sendJoin();
|
||||
loadDanmaku();
|
||||
statsInterval = setInterval(fetchStats, 8000);
|
||||
});
|
||||
function initApp() {
|
||||
loadFromConfig(function(config) {
|
||||
if (!config) {
|
||||
showError('无法加载 config.json 或图片,请通过 http 访问。');
|
||||
return;
|
||||
}
|
||||
fetchStats();
|
||||
sendView();
|
||||
sendJoin();
|
||||
statsInterval = setInterval(fetchStats, 8000);
|
||||
});
|
||||
}
|
||||
|
||||
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: '' }); })
|
||||
.then(function(apiConfig) {
|
||||
API = (apiConfig.apiBase || '').replace(/\/$/, '') + '/api';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', API + '/config', true);
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try { applyBackendConfig(JSON.parse(xhr.responseText)); } catch (e) {}
|
||||
}
|
||||
initApp();
|
||||
};
|
||||
xhr.onerror = function() { initApp(); };
|
||||
xhr.send();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', sendLeave);
|
||||
window.addEventListener('pagehide', sendLeave);
|
||||
@@ -362,8 +455,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
var introText = '';
|
||||
document.getElementById('btnIntro').addEventListener('click', function() {
|
||||
alert('简介:可在 config.json 中配置 intro 文案。');
|
||||
if (introText) {
|
||||
document.getElementById('introContent').textContent = introText;
|
||||
document.getElementById('introModal').classList.remove('hide');
|
||||
} else {
|
||||
alert('暂无简介。可在 config.json 的 intro 字段中填写。');
|
||||
}
|
||||
});
|
||||
document.getElementById('introModalClose').addEventListener('click', function() {
|
||||
document.getElementById('introModal').classList.add('hide');
|
||||
});
|
||||
|
||||
document.getElementById('btnShare').addEventListener('click', function() {
|
||||
@@ -393,8 +495,7 @@
|
||||
}
|
||||
|
||||
document.getElementById('btnLike').addEventListener('click', function() {
|
||||
var btn = this;
|
||||
if (btn.classList.contains('selected')) return;
|
||||
showLikeFloat();
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', API + '/like', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
@@ -402,7 +503,6 @@
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
btn.classList.add('selected');
|
||||
updateStatsUI({ likeCount: r.likeCount });
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -412,11 +512,9 @@
|
||||
|
||||
var modal = document.getElementById('commentModal');
|
||||
var commentContent = document.getElementById('commentContent');
|
||||
var commentNickname = document.getElementById('commentNickname');
|
||||
|
||||
document.getElementById('btnComment').addEventListener('click', function() {
|
||||
commentContent.value = '';
|
||||
commentNickname.value = localStorage.getItem('pano_nickname') || '';
|
||||
modal.classList.remove('hide');
|
||||
commentContent.focus();
|
||||
});
|
||||
@@ -427,12 +525,10 @@
|
||||
|
||||
document.getElementById('commentSubmit').addEventListener('click', function() {
|
||||
var content = (commentContent.value || '').trim();
|
||||
var nickname = (commentNickname.value || '').trim();
|
||||
if (!content) {
|
||||
alert('请输入留言内容');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('pano_nickname', nickname);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', API + '/comments', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
@@ -441,14 +537,15 @@
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var c = JSON.parse(xhr.responseText);
|
||||
addDanmakuLine(c.content, c.nickname);
|
||||
addDanmakuLine(c.content);
|
||||
fetchStats();
|
||||
} catch (e) {}
|
||||
} else {
|
||||
alert('发送失败');
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { alert('发送失败'); };
|
||||
xhr.send(JSON.stringify({ content: content, nickname: nickname || undefined }));
|
||||
xhr.send(JSON.stringify({ content: content }));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
92
server.js
92
server.js
@@ -1,102 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 静态资源 + API 服务。统计、点赞、分享、留言(弹幕)写入 SQLite。
|
||||
* 用法: node server.js [目录] 默认目录为项目根目录。
|
||||
* 本地/联合模式:静态资源 + 挂载后端 API。
|
||||
* 前端请求 /api/* 时由 server/ 提供;前后端分离部署时,前端使用 api.config.json 的 apiBase 指向独立后端。
|
||||
*/
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const db = require('./db.js');
|
||||
const apiApp = require('./server/server.js');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const ROOT = path.resolve(__dirname, process.argv[2] || '.');
|
||||
|
||||
db.initDb();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', apiApp);
|
||||
app.use(express.static(ROOT));
|
||||
|
||||
app.get('/api/stats', (req, res) => {
|
||||
try {
|
||||
res.json(db.getStats());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/view', (req, res) => {
|
||||
try {
|
||||
res.json(db.incView());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/like', (req, res) => {
|
||||
try {
|
||||
res.json(db.incLike());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/share', (req, res) => {
|
||||
try {
|
||||
res.json(db.incShare());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/join', (req, res) => {
|
||||
try {
|
||||
const viewerId = req.body && req.body.viewerId ? String(req.body.viewerId) : null;
|
||||
if (!viewerId) {
|
||||
return res.status(400).json({ error: 'viewerId required' });
|
||||
}
|
||||
const watchingNow = db.joinViewer(viewerId);
|
||||
res.json({ watchingNow });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/leave', (req, res) => {
|
||||
try {
|
||||
const viewerId = req.body && req.body.viewerId ? String(req.body.viewerId) : null;
|
||||
if (!viewerId) {
|
||||
return res.status(400).json({ error: 'viewerId required' });
|
||||
}
|
||||
const watchingNow = db.leaveViewer(viewerId);
|
||||
res.json({ watchingNow });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/comments', (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 200);
|
||||
res.json(db.getComments(limit));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/comments', (req, res) => {
|
||||
try {
|
||||
const content = req.body && req.body.content != null ? String(req.body.content) : '';
|
||||
const nickname = req.body && req.body.nickname != null ? String(req.body.nickname) : '';
|
||||
if (!content.trim()) {
|
||||
return res.status(400).json({ error: 'content required' });
|
||||
}
|
||||
const comment = db.addComment(content.trim(), nickname.trim() || null);
|
||||
res.json(comment);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log('已启动: http://localhost:' + PORT);
|
||||
console.log('已启动(静态+API): http://localhost:' + PORT);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SQLite 数据库:统计(观看、点赞、分享、在看)与留言(弹幕)
|
||||
* SQLite:统计、留言、后端配置(弹幕开关与位置)
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
@@ -38,16 +38,37 @@ function initDb() {
|
||||
nickname TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('danmaku_enabled', '0');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('danmaku_position', 'top');
|
||||
`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all();
|
||||
db.close();
|
||||
const map = {};
|
||||
rows.forEach((r) => { map[r.key] = r.value; });
|
||||
return {
|
||||
danmakuEnabled: map.danmaku_enabled === '1',
|
||||
danmakuPosition: (map.danmaku_position || 'top').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT view_count, like_count, share_count, watching_now FROM stats WHERE id = 1').get();
|
||||
const commentRow = db.prepare('SELECT COUNT(*) as n FROM comments').get();
|
||||
db.close();
|
||||
return {
|
||||
viewCount: row.view_count,
|
||||
commentCount: commentRow.n,
|
||||
likeCount: row.like_count,
|
||||
shareCount: row.share_count,
|
||||
watchingNow: row.watching_now,
|
||||
@@ -126,6 +147,7 @@ function addComment(content, nickname) {
|
||||
|
||||
module.exports = {
|
||||
initDb,
|
||||
getConfig,
|
||||
getStats,
|
||||
incView,
|
||||
incLike,
|
||||
10
server/index.js
Normal file
10
server/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 单独启动后端 API(仅 API 路由,不提供静态资源)。
|
||||
* 用于前后端分离部署时,将本目录部署到 Node 主机。
|
||||
*/
|
||||
const app = require('./server.js');
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log('API 已启动: http://localhost:' + PORT);
|
||||
});
|
||||
16
server/package.json
Normal file
16
server/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "720yun-offline-api",
|
||||
"version": "1.0.0",
|
||||
"description": "全景查看器后端 API,可单独部署",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
105
server/server.js
Normal file
105
server/server.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 后端 API 服务(可单独部署)。仅提供 /api/* 接口,不提供静态资源。
|
||||
* 弹幕开关与位置由 GET /api/config 返回(默认不显示弹幕)。
|
||||
*/
|
||||
const express = require('express');
|
||||
const db = require('./db.js');
|
||||
|
||||
db.initDb();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const corsOrigin = process.env.CORS_ORIGIN || '*';
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/config', (req, res) => {
|
||||
try {
|
||||
res.json(db.getConfig());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/stats', (req, res) => {
|
||||
try {
|
||||
res.json(db.getStats());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/view', (req, res) => {
|
||||
try {
|
||||
res.json(db.incView());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/like', (req, res) => {
|
||||
try {
|
||||
res.json(db.incLike());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/share', (req, res) => {
|
||||
try {
|
||||
res.json(db.incShare());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/join', (req, res) => {
|
||||
try {
|
||||
const viewerId = req.body && req.body.viewerId ? String(req.body.viewerId) : null;
|
||||
if (!viewerId) return res.status(400).json({ error: 'viewerId required' });
|
||||
const watchingNow = db.joinViewer(viewerId);
|
||||
res.json({ watchingNow });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/leave', (req, res) => {
|
||||
try {
|
||||
const viewerId = req.body && req.body.viewerId ? String(req.body.viewerId) : null;
|
||||
if (!viewerId) return res.status(400).json({ error: 'viewerId required' });
|
||||
const watchingNow = db.leaveViewer(viewerId);
|
||||
res.json({ watchingNow });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/comments', (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 200);
|
||||
res.json(db.getComments(limit));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/comments', (req, res) => {
|
||||
try {
|
||||
const content = req.body && req.body.content != null ? String(req.body.content) : '';
|
||||
const nickname = req.body && req.body.nickname != null ? String(req.body.nickname) : '';
|
||||
if (!content.trim()) return res.status(400).json({ error: 'content required' });
|
||||
const comment = db.addComment(content.trim(), nickname.trim() || null);
|
||||
res.json(comment);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
Reference in New Issue
Block a user