feat: 初始化项目
This commit is contained in:
159
server/db.js
Normal file
159
server/db.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* SQLite:统计、留言、后端配置(弹幕开关与位置)
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data', 'pano.db');
|
||||
|
||||
function getDb() {
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
const fs = require('fs');
|
||||
const dir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
view_count INTEGER NOT NULL DEFAULT 0,
|
||||
like_count INTEGER NOT NULL DEFAULT 0,
|
||||
share_count INTEGER NOT NULL DEFAULT 0,
|
||||
watching_now INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO stats (id, view_count, like_count, share_count, watching_now) VALUES (1, 0, 0, 0, 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS viewers (
|
||||
viewer_id TEXT PRIMARY KEY,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function incView() {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE stats SET view_count = view_count + 1 WHERE id = 1').run();
|
||||
const out = getStats();
|
||||
db.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
function incLike() {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE stats SET like_count = like_count + 1 WHERE id = 1').run();
|
||||
const row = db.prepare('SELECT like_count FROM stats WHERE id = 1').get();
|
||||
db.close();
|
||||
return { likeCount: row.like_count };
|
||||
}
|
||||
|
||||
function incShare() {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE stats SET share_count = share_count + 1 WHERE id = 1').run();
|
||||
const row = db.prepare('SELECT share_count FROM stats WHERE id = 1').get();
|
||||
db.close();
|
||||
return { shareCount: row.share_count };
|
||||
}
|
||||
|
||||
function joinViewer(viewerId) {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
db.prepare('INSERT OR REPLACE INTO viewers (viewer_id, updated_at) VALUES (?, ?)').run(viewerId, now);
|
||||
db.prepare('DELETE FROM viewers WHERE updated_at < ?').run(now - 120000);
|
||||
const row = db.prepare('SELECT COUNT(*) as n FROM viewers').get();
|
||||
db.prepare('UPDATE stats SET watching_now = ? WHERE id = 1').run(row.n);
|
||||
db.close();
|
||||
return row.n;
|
||||
}
|
||||
|
||||
function leaveViewer(viewerId) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM viewers WHERE viewer_id = ?').run(viewerId);
|
||||
const row = db.prepare('SELECT COUNT(*) as n FROM viewers').get();
|
||||
db.prepare('UPDATE stats SET watching_now = ? WHERE id = 1').run(row.n);
|
||||
db.close();
|
||||
return row.n;
|
||||
}
|
||||
|
||||
function getComments(limit = 100) {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT id, content, nickname, created_at FROM comments ORDER BY id DESC LIMIT ?'
|
||||
).all(limit);
|
||||
db.close();
|
||||
return rows.reverse().map((r) => ({
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
nickname: r.nickname || '游客',
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
function addComment(content, nickname) {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
const r = db.prepare('INSERT INTO comments (content, nickname, created_at) VALUES (?, ?, ?)').run(
|
||||
String(content).trim().slice(0, 200) || '(空)',
|
||||
nickname ? String(nickname).trim().slice(0, 32) : null,
|
||||
now
|
||||
);
|
||||
db.close();
|
||||
return { id: r.lastInsertRowid, content: content.trim().slice(0, 200), nickname: nickname || '游客', createdAt: now };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initDb,
|
||||
getConfig,
|
||||
getStats,
|
||||
incView,
|
||||
incLike,
|
||||
incShare,
|
||||
joinViewer,
|
||||
leaveViewer,
|
||||
getComments,
|
||||
addComment,
|
||||
};
|
||||
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