feat: 初始化项目
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ panorama/*.webp
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
data/
|
data/
|
||||||
|
server/data/
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -6,24 +6,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构(前后端分离)
|
||||||
|
|
||||||
```
|
```
|
||||||
720yun-offline/
|
720yun-offline/
|
||||||
├── index.html # 本地全景查看页(Pannellum)
|
├── index.html # 前端页面(Pannellum + 统计/弹幕)
|
||||||
├── config.json # 全景配置(默认使用 image/ 六面图)
|
├── config.json # 全景配置(六面图、简介等)
|
||||||
├── lib/
|
├── api.config.json # 前端用:API 根地址,空为同源
|
||||||
│ ├── pannellum.js
|
├── lib/ # Pannellum
|
||||||
│ └── pannellum.css
|
├── image/ # 六面体全景图
|
||||||
├── image/ # 六面体全景图(mobile_f/r/b/l/u/d.jpg),默认展示来源
|
├── server.js # 本地联合模式:静态 + 挂载 server/ API
|
||||||
├── panorama/ # 单张全景图(可选)
|
├── build.js # 构建前端静态到 dist/
|
||||||
├── server.js # Node 静态 + API 服务(统计、留言、弹幕)
|
├── package.json # 根目录:start / build / preview
|
||||||
├── db.js # SQLite 封装(data/pano.db)
|
├── server/ # 后端(可单独部署)
|
||||||
├── data/ # 数据库目录(自动创建,已 gitignore)
|
│ ├── server.js # Express API 路由
|
||||||
├── build.js # 构建脚本,产出 dist/ 便于部署
|
│ ├── db.js # SQLite(统计、留言、settings 弹幕开关)
|
||||||
├── package.json # Node 脚本:start / build / preview
|
│ ├── index.js # 单独启动 API:node index.js
|
||||||
├── fetch_720yun.py # 自动抓取脚本(若页面有直出 URL 则可用)
|
│ ├── package.json # 后端依赖
|
||||||
├── parse_720yun_doc.py # 从 text.md 解析 URL,--download 下载到 image/
|
│ └── data/ # pano.db(自动创建)
|
||||||
|
├── docs/部署说明.md # 前后端分离部署与弹幕配置
|
||||||
└── README.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, 'index.html'), path.join(DIST, 'index.html'));
|
||||||
copyFile(path.join(ROOT, 'config.json'), path.join(DIST, 'config.json'));
|
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, 'lib'), path.join(DIST, 'lib'));
|
||||||
copyDir(path.join(ROOT, 'image'), path.join(DIST, 'image'));
|
copyDir(path.join(ROOT, 'image'), path.join(DIST, 'image'));
|
||||||
|
|
||||||
console.log('已构建到 dist/,可直接部署 dist 目录。');
|
console.log('已构建到 dist/(前端静态),可直接部署。');
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"image/mobile_u.jpg",
|
"image/mobile_u.jpg",
|
||||||
"image/mobile_d.jpg"
|
"image/mobile_d.jpg"
|
||||||
],
|
],
|
||||||
"title": "广兴镇蔡家庵纪念 全景离线",
|
"title": "四川省广兴镇蔡家庵 全景图(纪念版)",
|
||||||
"autoLoad": true,
|
"autoLoad": true,
|
||||||
"showControls": true,
|
"showControls": true,
|
||||||
"hfov": 100,
|
"hfov": 100,
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"maxHfov": 120,
|
"maxHfov": 120,
|
||||||
"authorName": "创作者:广兴镇蔡家11组原住民",
|
"authorName": "创作者:广兴镇蔡家11组原住民",
|
||||||
"authorUrl": "",
|
"authorUrl": "",
|
||||||
|
"intro": "这里曾是一座依山而居、世代相传的原始村落。青瓦土墙、石板小路、院落炊烟,记录着村民们质朴而悠长的生活记忆。随着时代的发展,国家重大交通工程 西渝高铁 的线路从这里经过,为区域带来新的机遇与活力。为了配合建设,村民们整体搬迁至新的安置点,开启了更加便利、现代的生活。如今,这片旧址被完整保留下来,以最原始的村落风貌呈现在人们面前。通过全景影像,曾经的院落布局、街巷肌理与自然环境得以真实记录,也让人们得以重新走进这段乡村记忆。这幅全景图不仅是一份空间记录,更是一段关于 时代变迁、乡愁记忆与发展轨迹 的见证。旧村虽迁,但生活的印记与文化的脉络,依然在这片土地上静静延续。",
|
||||||
"viewCount": 0,
|
"viewCount": 0,
|
||||||
"watchingNow": 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` 保持为 `""` 即可。
|
||||||
201
index.html
201
index.html
@@ -2,11 +2,12 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
<title>全景查看</title>
|
||||||
<link rel="stylesheet" href="lib/pannellum.css">
|
<link rel="stylesheet" href="lib/pannellum.css">
|
||||||
<style>
|
<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%; }
|
.pano-root { flex: 1 1 0%; position: relative; width: 100%; height: 100%; }
|
||||||
#krp { position: absolute; inset: 0; z-index: 0; }
|
#krp { position: absolute; inset: 0; z-index: 0; }
|
||||||
#player_krp {
|
#player_krp {
|
||||||
@@ -23,14 +24,17 @@
|
|||||||
}
|
}
|
||||||
.load-error a { color: #08c; }
|
.load-error a { color: #08c; }
|
||||||
|
|
||||||
/* 弹幕层 */
|
/* 弹幕层:默认不显示,由后端 GET /api/config 的 danmakuEnabled 控制;位置在顶部 */
|
||||||
#danmaku-wrap {
|
#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;
|
pointer-events: none; z-index: 5; overflow: hidden;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
#danmaku-wrap.danmaku-on { display: block; }
|
||||||
.danmaku-line {
|
.danmaku-line {
|
||||||
position: absolute; white-space: nowrap;
|
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;
|
animation: danmaku-scroll 15s linear forwards;
|
||||||
}
|
}
|
||||||
@keyframes danmaku-scroll {
|
@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; }
|
.topWp > * { pointer-events: auto; }
|
||||||
.LeftBtn { position: absolute; left: 0; top: 12px; padding: 0 16px; }
|
.LeftBtn {
|
||||||
.authorPvBox { margin-bottom: 8px; }
|
position: absolute; left: 0; top: 12px; padding: 0 12px 0 max(12px, env(safe-area-inset-left));
|
||||||
.Author { color: #fff; text-decoration: none; font-size: 13px; opacity: 1; }
|
max-width: calc(100vw - 100px);
|
||||||
.view-stats { color: rgba(255,255,255,.7); font-size: 12px; margin-top: 4px; }
|
}
|
||||||
.view-stats span { margin-right: 12px; }
|
.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 {
|
.RightBtn .btn-wrap {
|
||||||
display: flex; flex-direction: column; align-items: center; cursor: pointer;
|
display: flex; flex-direction: column; align-items: center; cursor: pointer;
|
||||||
padding: 6px 10px; background: rgba(0,0,0,.3); border-radius: 6px; color: #fff;
|
padding: clamp(6px, 2vw, 10px) clamp(8px, 2.5vw, 12px);
|
||||||
font-size: 12px; border: none; font-family: inherit;
|
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:hover { background: rgba(0,0,0,.5); }
|
||||||
.RightBtn .btn-wrap svg { width: 24px; height: 24px; margin-bottom: 2px; }
|
.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; }
|
.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 {
|
.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;
|
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:hover { background: rgba(0,0,0,.5); }
|
||||||
.bottom_right .CustomButton.selected { border-color: #fa6400; }
|
.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); }
|
.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-mask.hide { display: none; }
|
||||||
.modal-box { background: #333; color: #fff; border-radius: 12px; padding: 20px; min-width: 280px; max-width: 90vw; }
|
.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: 16px; }
|
.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: 8px 10px; margin-bottom: 10px; border: 1px solid #555; border-radius: 6px; background: #222; color: #fff; font-size: 14px; }
|
.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: 72px; resize: vertical; }
|
.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 { 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 button { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||||
.modal-box .btns .btn-ok { background: #fa6400; color: #fff; }
|
.modal-box .btns .btn-ok { background: #fa6400; color: #fff; }
|
||||||
@@ -104,8 +135,11 @@
|
|||||||
<a class="Author" href="#" target="_blank" rel="noopener" id="authorName">创作者:本地</a>
|
<a class="Author" href="#" target="_blank" rel="noopener" id="authorName">创作者:本地</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-stats">
|
<div class="view-stats">
|
||||||
<span id="watchingNow">0 人在看</span>
|
<span class="stat-item" id="watchingNow">0 人在看</span>
|
||||||
<span id="viewCount">共 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>
|
</div>
|
||||||
<div class="RightBtn">
|
<div class="RightBtn">
|
||||||
@@ -119,10 +153,10 @@
|
|||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<div class="bottomWp">
|
<div class="bottomWp">
|
||||||
<div class="bottom_right">
|
<div class="bottom_right">
|
||||||
<div class="CustomButton" id="btnIntro" title="简介">简介</div>
|
<div class="CustomButton" id="btnIntro" title="简介"><span class="btn-label">简介</span></div>
|
||||||
<div class="CustomButton" id="btnShare" title="分享">分享 <span class="count" id="shareCount">0</span></div>
|
<div class="CustomButton" id="btnShare" title="分享"><span class="btn-label">分享</span></div>
|
||||||
<div class="CustomButton" id="btnLike" title="赞">赞 <span class="count" id="likeCount">0</span></div>
|
<div class="CustomButton" id="btnLike" title="赞"><span class="btn-label">赞</span></div>
|
||||||
<div class="CustomButton" id="btnComment" title="留言">留言</div>
|
<div class="CustomButton" id="btnComment" title="留言"><span class="btn-label">留言</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="safeHeight"></div>
|
<div class="safeHeight"></div>
|
||||||
@@ -130,10 +164,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="commentModal" class="modal-mask hide">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3>留言(弹幕)</h3>
|
<h3>留言(弹幕)</h3>
|
||||||
<input type="text" id="commentNickname" placeholder="昵称(选填)" maxlength="32">
|
|
||||||
<textarea id="commentContent" placeholder="说点什么…" maxlength="200"></textarea>
|
<textarea id="commentContent" placeholder="说点什么…" maxlength="200"></textarea>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<button type="button" class="btn-cancel" id="commentCancel">取消</button>
|
<button type="button" class="btn-cancel" id="commentCancel">取消</button>
|
||||||
@@ -210,11 +255,30 @@
|
|||||||
var w = document.getElementById('watchingNow');
|
var w = document.getElementById('watchingNow');
|
||||||
if (w) w.textContent = (stats.watchingNow || 0) + ' 人在看';
|
if (w) w.textContent = (stats.watchingNow || 0) + ' 人在看';
|
||||||
var v = document.getElementById('viewCount');
|
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');
|
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');
|
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) {
|
function fetchStats(cb) {
|
||||||
@@ -268,13 +332,13 @@
|
|||||||
viewerId = null;
|
viewerId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDanmakuLine(text, nickname) {
|
function addDanmakuLine(text) {
|
||||||
var wrap = document.getElementById('danmaku-wrap');
|
var wrap = document.getElementById('danmaku-wrap');
|
||||||
if (!wrap) return;
|
if (!wrap || !wrap.classList.contains('danmaku-on')) return;
|
||||||
var line = document.createElement('div');
|
var line = document.createElement('div');
|
||||||
line.className = 'danmaku-line';
|
line.className = 'danmaku-line';
|
||||||
line.textContent = (nickname ? nickname + ':' : '') + text;
|
line.textContent = text || '';
|
||||||
line.style.top = (10 + Math.random() * 75) + '%';
|
line.style.top = (2 + Math.random() * 16) + '%';
|
||||||
line.style.animationDuration = (12 + Math.random() * 8) + 's';
|
line.style.animationDuration = (12 + Math.random() * 8) + 's';
|
||||||
wrap.appendChild(line);
|
wrap.appendChild(line);
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
@@ -294,8 +358,8 @@
|
|||||||
list.forEach(function(c, i) {
|
list.forEach(function(c, i) {
|
||||||
var line = document.createElement('div');
|
var line = document.createElement('div');
|
||||||
line.className = 'danmaku-line';
|
line.className = 'danmaku-line';
|
||||||
line.textContent = (c.nickname || '游客') + ':' + (c.content || '');
|
line.textContent = c.content || '';
|
||||||
line.style.top = (10 + (i % 8) * 10 + Math.random() * 5) + '%';
|
line.style.top = (2 + (i % 6) * 3 + Math.random() * 2) + '%';
|
||||||
line.style.animationDuration = (14 + (i % 5)) + 's';
|
line.style.animationDuration = (14 + (i % 5)) + 's';
|
||||||
line.style.animationDelay = (i * 0.8) + 's';
|
line.style.animationDelay = (i * 0.8) + 's';
|
||||||
wrap.appendChild(line);
|
wrap.appendChild(line);
|
||||||
@@ -306,12 +370,24 @@
|
|||||||
xhr.send();
|
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) {
|
function updateUIFromConfig(config) {
|
||||||
var authorEl = document.getElementById('authorName');
|
var authorEl = document.getElementById('authorName');
|
||||||
if (authorEl && config.authorName) {
|
if (authorEl && config.authorName) {
|
||||||
authorEl.textContent = config.authorName;
|
authorEl.textContent = config.authorName;
|
||||||
if (config.authorUrl) authorEl.href = config.authorUrl;
|
if (config.authorUrl) authorEl.href = config.authorUrl;
|
||||||
}
|
}
|
||||||
|
introText = (config.intro != null && config.intro !== '') ? String(config.intro) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromConfig(cb) {
|
function loadFromConfig(cb) {
|
||||||
@@ -339,17 +415,34 @@
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initApp() {
|
||||||
loadFromConfig(function(config) {
|
loadFromConfig(function(config) {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
showError('无法加载 config.json 或图片,请通过 http 访问(如 npm start)。');
|
showError('无法加载 config.json 或图片,请通过 http 访问。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchStats();
|
fetchStats();
|
||||||
sendView();
|
sendView();
|
||||||
sendJoin();
|
sendJoin();
|
||||||
loadDanmaku();
|
|
||||||
statsInterval = setInterval(fetchStats, 8000);
|
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('beforeunload', sendLeave);
|
||||||
window.addEventListener('pagehide', sendLeave);
|
window.addEventListener('pagehide', sendLeave);
|
||||||
@@ -362,8 +455,17 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var introText = '';
|
||||||
document.getElementById('btnIntro').addEventListener('click', function() {
|
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() {
|
document.getElementById('btnShare').addEventListener('click', function() {
|
||||||
@@ -393,8 +495,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('btnLike').addEventListener('click', function() {
|
document.getElementById('btnLike').addEventListener('click', function() {
|
||||||
var btn = this;
|
showLikeFloat();
|
||||||
if (btn.classList.contains('selected')) return;
|
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', API + '/like', true);
|
xhr.open('POST', API + '/like', true);
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
@@ -402,7 +503,6 @@
|
|||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
try {
|
try {
|
||||||
var r = JSON.parse(xhr.responseText);
|
var r = JSON.parse(xhr.responseText);
|
||||||
btn.classList.add('selected');
|
|
||||||
updateStatsUI({ likeCount: r.likeCount });
|
updateStatsUI({ likeCount: r.likeCount });
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -412,11 +512,9 @@
|
|||||||
|
|
||||||
var modal = document.getElementById('commentModal');
|
var modal = document.getElementById('commentModal');
|
||||||
var commentContent = document.getElementById('commentContent');
|
var commentContent = document.getElementById('commentContent');
|
||||||
var commentNickname = document.getElementById('commentNickname');
|
|
||||||
|
|
||||||
document.getElementById('btnComment').addEventListener('click', function() {
|
document.getElementById('btnComment').addEventListener('click', function() {
|
||||||
commentContent.value = '';
|
commentContent.value = '';
|
||||||
commentNickname.value = localStorage.getItem('pano_nickname') || '';
|
|
||||||
modal.classList.remove('hide');
|
modal.classList.remove('hide');
|
||||||
commentContent.focus();
|
commentContent.focus();
|
||||||
});
|
});
|
||||||
@@ -427,12 +525,10 @@
|
|||||||
|
|
||||||
document.getElementById('commentSubmit').addEventListener('click', function() {
|
document.getElementById('commentSubmit').addEventListener('click', function() {
|
||||||
var content = (commentContent.value || '').trim();
|
var content = (commentContent.value || '').trim();
|
||||||
var nickname = (commentNickname.value || '').trim();
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
alert('请输入留言内容');
|
alert('请输入留言内容');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem('pano_nickname', nickname);
|
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', API + '/comments', true);
|
xhr.open('POST', API + '/comments', true);
|
||||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
@@ -441,14 +537,15 @@
|
|||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
try {
|
try {
|
||||||
var c = JSON.parse(xhr.responseText);
|
var c = JSON.parse(xhr.responseText);
|
||||||
addDanmakuLine(c.content, c.nickname);
|
addDanmakuLine(c.content);
|
||||||
|
fetchStats();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
alert('发送失败');
|
alert('发送失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onerror = function() { alert('发送失败'); };
|
xhr.onerror = function() { alert('发送失败'); };
|
||||||
xhr.send(JSON.stringify({ content: content, nickname: nickname || undefined }));
|
xhr.send(JSON.stringify({ content: content }));
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
92
server.js
92
server.js
@@ -1,102 +1,20 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* 静态资源 + API 服务。统计、点赞、分享、留言(弹幕)写入 SQLite。
|
* 本地/联合模式:静态资源 + 挂载后端 API。
|
||||||
* 用法: node server.js [目录] 默认目录为项目根目录。
|
* 前端请求 /api/* 时由 server/ 提供;前后端分离部署时,前端使用 api.config.json 的 apiBase 指向独立后端。
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('./db.js');
|
const apiApp = require('./server/server.js');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const ROOT = path.resolve(__dirname, process.argv[2] || '.');
|
const ROOT = path.resolve(__dirname, process.argv[2] || '.');
|
||||||
|
|
||||||
db.initDb();
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use('/api', apiApp);
|
||||||
app.use(express.static(ROOT));
|
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, () => {
|
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 Database = require('better-sqlite3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -38,16 +38,37 @@ function initDb() {
|
|||||||
nickname TEXT,
|
nickname TEXT,
|
||||||
created_at INTEGER NOT NULL
|
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();
|
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() {
|
function getStats() {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const row = db.prepare('SELECT view_count, like_count, share_count, watching_now FROM stats WHERE id = 1').get();
|
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();
|
db.close();
|
||||||
return {
|
return {
|
||||||
viewCount: row.view_count,
|
viewCount: row.view_count,
|
||||||
|
commentCount: commentRow.n,
|
||||||
likeCount: row.like_count,
|
likeCount: row.like_count,
|
||||||
shareCount: row.share_count,
|
shareCount: row.share_count,
|
||||||
watchingNow: row.watching_now,
|
watchingNow: row.watching_now,
|
||||||
@@ -126,6 +147,7 @@ function addComment(content, nickname) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
initDb,
|
initDb,
|
||||||
|
getConfig,
|
||||||
getStats,
|
getStats,
|
||||||
incView,
|
incView,
|
||||||
incLike,
|
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