From 42aeb84c64b443150a7f0501198f48cbace67b68 Mon Sep 17 00:00:00 2001 From: "yinzhou.ma" Date: Thu, 16 Jul 2026 22:58:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/schema.sql | 7 + public/admin.html | 628 ++++++++++++++++++++------------------------ server.js | 449 ++++++++++++++++++------------- 3 files changed, 560 insertions(+), 524 deletions(-) diff --git a/database/schema.sql b/database/schema.sql index b30e3e1..c236c36 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -22,3 +22,10 @@ CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT ); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) +); diff --git a/public/admin.html b/public/admin.html index d8cb76c..dda227a 100644 --- a/public/admin.html +++ b/public/admin.html @@ -8,103 +8,66 @@ +
+

后台管理

+ +
@@ -113,372 +76,365 @@ + 已关闭
- 首页 - + +
- +
- - - - - - - - - - - - + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/server.js b/server.js index e03143f..596d061 100644 --- a/server.js +++ b/server.js @@ -4,54 +4,37 @@ const multer = require('multer'); const initSqlJs = require('sql.js'); const { v4: uuidv4 } = require('uuid'); const fs = require('fs'); +const WebSocket = require('ws'); -const app = express(); -const PORT = 3000; +const ADMIN_PORT = 16888; +const FRONT_PORT = 80; let db; async function initDB() { const SQL = await initSqlJs(); const dbPath = path.join(__dirname, 'data', 'sqlite.db'); - if (fs.existsSync(dbPath)) { - const fileBuffer = fs.readFileSync(dbPath); - db = new SQL.Database(fileBuffer); + db = new SQL.Database(fs.readFileSync(dbPath)); } else { db = new SQL.Database(); } + db.run(fs.readFileSync(path.join(__dirname, 'database', 'schema.sql'), 'utf8')); + + const defaults = { + 'feature_enabled': 'false', + 'current_group_index': '0', + 'current_group_start_time': '0' + }; + for (const [k, v] of Object.entries(defaults)) { + const r = runQuery("SELECT value FROM config WHERE key=?", [k]); + if (r.length === 0) db.run("INSERT INTO config (key,value) VALUES (?,?)", [k, v]); + } - const schema = ` - CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - display_seconds INTEGER NOT NULL DEFAULT 30, - enabled INTEGER NOT NULL DEFAULT 1, - update_time INTEGER NOT NULL DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE TABLE IF NOT EXISTS images ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_id INTEGER NOT NULL, - filename TEXT NOT NULL, - original_name TEXT, - size INTEGER DEFAULT 0, - width INTEGER DEFAULT 280, - height INTEGER DEFAULT 280, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT - ); - `; - db.run(schema); - - // 确保有 config 行 - const configCheck = db.exec("SELECT COUNT(*) as cnt FROM config WHERE key='feature_enabled'"); - if (configCheck[0].values[0][0] === 0) { - db.run("INSERT INTO config (key, value) VALUES ('feature_enabled', 'false')"); + // 确保有默认管理员账号 + const userCheck = runQuery("SELECT COUNT(*) as cnt FROM users WHERE username=?", ['admin']); + if (userCheck.length === 0 || userCheck[0].cnt === 0) { + db.run("INSERT INTO users (username, password) VALUES (?,?)", ['admin', 'admin123']); } saveDB(); @@ -59,232 +42,322 @@ async function initDB() { function saveDB() { const data = db.export(); - const buffer = Buffer.from(data); - fs.writeFileSync(path.join(__dirname, 'data', 'sqlite.db'), buffer); + fs.writeFileSync(path.join(__dirname, 'data', 'sqlite.db'), Buffer.from(data)); } function runQuery(sql, params) { const stmt = db.prepare(sql); - if (params && params.length > 0) { - stmt.bind(params); - } + if (params && params.length > 0) stmt.bind(params); const results = []; - while (stmt.step()) { - results.push(stmt.getAsObject()); - } + while (stmt.step()) results.push(stmt.getAsObject()); stmt.free(); return results; } function runExec(sql, params) { db.run(sql, params || []); +} + +function getConfig(key) { + const r = runQuery("SELECT value FROM config WHERE key=?", [key]); + return r.length > 0 ? r[0].value : null; +} + +function setConfig(key, value) { + runExec("INSERT OR REPLACE INTO config (key,value) VALUES (?,?)", [key, String(value)]); saveDB(); } -// Multer配置 -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, path.join(__dirname, 'uploads')); - }, - filename: function (req, file, cb) { - const ext = path.extname(file.originalname); - cb(null, uuidv4() + ext); +function getEnabledGroups() { + return runQuery(` + SELECT g.*, (SELECT COUNT(*) FROM images WHERE group_id=g.id AND enabled=1) as img_count + FROM groups g WHERE g.enabled=1 ORDER BY g.id ASC + `).filter(g => g.img_count > 0); +} + +// ========== 轮播逻辑 ========== +let wsClients = new Set(); + +function broadcast(data) { + const msg = JSON.stringify(data); + wsClients.forEach(ws => { + if (ws.readyState === WebSocket.OPEN) ws.send(msg); + }); +} + +function getCurrentState() { + const featureEnabled = getConfig('feature_enabled') === 'true'; + if (!featureEnabled) return { type: 'state', playing: false, groups: [] }; + + const groups = getEnabledGroups(); + if (groups.length === 0) return { type: 'state', playing: false, groups: [] }; + + const idx = parseInt(getConfig('current_group_index') || '0') % groups.length; + const startTime = parseInt(getConfig('current_group_start_time') || '0'); + const now = Math.floor(Date.now() / 1000); + const currentGroup = groups[idx]; + + let remaining = 0; + if (currentGroup.display_seconds === 0) { + remaining = -1; + } else if (startTime > 0) { + remaining = Math.max(0, startTime + currentGroup.display_seconds - now); } + + const images = runQuery('SELECT * FROM images WHERE group_id=? AND enabled=1', [currentGroup.id]); + + return { + type: 'state', + playing: true, + current_index: idx, + total: groups.length, + group_name: currentGroup.name, + group_id: currentGroup.id, + display_seconds: currentGroup.display_seconds, + remaining: remaining, + images: images + }; +} + +function broadcastState() { + broadcast(getCurrentState()); +} + +function startCarouselTimer() { + setInterval(() => { + if (getConfig('feature_enabled') !== 'true') return; + + const groups = getEnabledGroups(); + if (groups.length === 0) return; + + const idx = parseInt(getConfig('current_group_index') || '0') % groups.length; + const startTime = parseInt(getConfig('current_group_start_time') || '0'); + const currentGroup = groups[idx]; + + if (currentGroup.display_seconds === 0) { + broadcastState(); + return; + } + + if (startTime === 0) { + setConfig('current_group_start_time', Math.floor(Date.now() / 1000)); + broadcastState(); + return; + } + + const now = Math.floor(Date.now() / 1000); + const elapsed = now - startTime; + + if (elapsed >= currentGroup.display_seconds) { + const nextIdx = findNextGroupIndex(groups, idx); + setConfig('current_group_index', nextIdx); + setConfig('current_group_start_time', Math.floor(Date.now() / 1000)); + } + broadcastState(); + }, 1000); +} + +function findNextGroupIndex(groups, currentIdx) { + for (let i = 1; i <= groups.length; i++) { + const nextIdx = (currentIdx + i) % groups.length; + if (groups[nextIdx].display_seconds > 0) return nextIdx; + } + return currentIdx; +} + +function resetCarousel() { + setConfig('current_group_index', 0); + setConfig('current_group_start_time', Math.floor(Date.now() / 1000)); + broadcastState(); +} + +// ========== Multer ========== +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, path.join(__dirname, 'uploads')), + filename: (req, file, cb) => cb(null, uuidv4() + path.extname(file.originalname)) }); -const upload = multer({ storage: storage }); +const upload = multer({ storage }); -// 中间件 -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(express.static(path.join(__dirname, 'public'))); -app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +// ========== 前台服务 (端口 80) ========== +const frontApp = express(); +frontApp.use(express.static(path.join(__dirname, 'public'))); +frontApp.use('/uploads', express.static(path.join(__dirname, 'uploads'))); + +frontApp.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +const frontServer = frontApp.listen(FRONT_PORT, '0.0.0.0', () => { + console.log(`前台运行在 http://0.0.0.0:${FRONT_PORT}`); +}); + +const wss = new WebSocket.Server({ server: frontServer }); +wss.on('connection', (ws) => { + wsClients.add(ws); + ws.send(JSON.stringify(getCurrentState())); + ws.on('close', () => wsClients.delete(ws)); +}); + +// ========== 后台服务 (端口 18888) ========== +const adminApp = express(); +adminApp.use(express.json()); +adminApp.use(express.urlencoded({ extended: true })); +adminApp.use(express.static(path.join(__dirname, 'public'))); +adminApp.use('/uploads', express.static(path.join(__dirname, 'uploads'))); -// 认证中间件 const authMiddleware = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; - if (!token || token !== 'admin-token') { - return res.status(401).json({ error: '未授权' }); - } + if (!token || token !== 'admin-token') return res.status(401).json({ error: '未授权' }); next(); }; -// 认证API -app.post('/api/login', (req, res) => { +// 登录 +adminApp.post('/api/login', (req, res) => { const { username, password } = req.body; - if (username === 'admin' && password === 'admin123') { + const users = runQuery('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]); + if (users.length > 0) { res.json({ success: true, token: 'admin-token' }); } else { res.status(401).json({ success: false, error: '用户名或密码错误' }); } }); -// 分组管理API -app.get('/api/groups', (req, res) => { - const groups = runQuery('SELECT * FROM groups ORDER BY id ASC'); - res.json(groups); +// 修改密码 +adminApp.put('/api/change-password', authMiddleware, (req, res) => { + const { oldPassword, newPassword } = req.body; + if (!oldPassword || !newPassword) { + return res.status(400).json({ error: '请输入旧密码和新密码' }); + } + const users = runQuery('SELECT * FROM users WHERE username = ? AND password = ?', ['admin', oldPassword]); + if (users.length === 0) { + return res.status(400).json({ error: '旧密码错误' }); + } + runExec('UPDATE users SET password = ? WHERE username = ?', [newPassword, 'admin']); + saveDB(); + res.json({ success: true }); }); -app.post('/api/groups', authMiddleware, (req, res) => { +// 分组 +adminApp.get('/api/groups', (req, res) => { + res.json(runQuery('SELECT * FROM groups ORDER BY id ASC')); +}); + +adminApp.post('/api/groups', authMiddleware, (req, res) => { const { name, display_seconds } = req.body; - runExec('INSERT INTO groups (name, display_seconds, update_time) VALUES (?, ?, 0)', - [name || '新分组', display_seconds || 30]); - const result = db.exec("SELECT last_insert_rowid() as id"); - const id = result[0].values[0][0]; + runExec('INSERT INTO groups (name, display_seconds) VALUES (?, ?)', [name || '新分组', display_seconds || 30]); + saveDB(); + const id = db.exec("SELECT last_insert_rowid()")[0].values[0][0]; + broadcastState(); res.json({ id }); }); -app.put('/api/groups/:id', authMiddleware, (req, res) => { +adminApp.put('/api/groups/:id', authMiddleware, (req, res) => { const { name, display_seconds, enabled } = req.body; - const id = parseInt(req.params.id); - runExec('UPDATE groups SET name = ?, display_seconds = ?, enabled = ? WHERE id = ?', - [name, display_seconds, enabled, id]); + runExec('UPDATE groups SET name=?, display_seconds=?, enabled=? WHERE id=?', + [name, display_seconds, enabled, parseInt(req.params.id)]); + saveDB(); + broadcastState(); res.json({ success: true }); }); -app.put('/api/groups/:id/settime', authMiddleware, (req, res) => { +adminApp.put('/api/groups/:id/settime', authMiddleware, (req, res) => { const { display_seconds } = req.body; - const id = parseInt(req.params.id); - const now = Math.floor(Date.now() / 1000); - runExec('UPDATE groups SET display_seconds = ?, update_time = ? WHERE id = ?', - [display_seconds, now, id]); + runExec('UPDATE groups SET display_seconds=? WHERE id=?', [display_seconds, parseInt(req.params.id)]); + saveDB(); + broadcastState(); res.json({ success: true }); }); -app.delete('/api/groups/:id', authMiddleware, (req, res) => { +adminApp.delete('/api/groups/:id', authMiddleware, (req, res) => { const id = parseInt(req.params.id); - // 先删除分组下的图片文件 - const images = runQuery('SELECT filename FROM images WHERE group_id = ?', [id]); - images.forEach(img => { - const filePath = path.join(__dirname, 'uploads', img.filename); - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + const imgs = runQuery('SELECT filename FROM images WHERE group_id=?', [id]); + imgs.forEach(img => { + const fp = path.join(__dirname, 'uploads', img.filename); + if (fs.existsSync(fp)) fs.unlinkSync(fp); }); - runExec('DELETE FROM images WHERE group_id = ?', [id]); - runExec('DELETE FROM groups WHERE id = ?', [id]); + runExec('DELETE FROM images WHERE group_id=?', [id]); + runExec('DELETE FROM groups WHERE id=?', [id]); + saveDB(); + resetCarousel(); res.json({ success: true }); }); -// 图片上传API -app.post('/api/upload', authMiddleware, upload.single('image'), (req, res) => { - if (!req.file) { - return res.status(400).json({ error: '没有上传文件' }); - } - const group_id = parseInt(req.body.group_id); - if (!group_id) { - return res.status(400).json({ error: '缺少分组ID' }); - } - const { filename, originalname, size } = req.file; - - runExec('INSERT INTO images (group_id, filename, original_name, size) VALUES (?, ?, ?, ?)', - [group_id, filename, originalname, size || 0]); - const result = db.exec("SELECT last_insert_rowid() as id"); - const id = result[0].values[0][0]; - res.json({ id, filename }); -}); - -// 图片列表API -app.get('/api/images', (req, res) => { - const group_id = parseInt(req.query.group_id); - let images; - if (group_id) { - images = runQuery('SELECT * FROM images WHERE group_id = ? ORDER BY id DESC', [group_id]); +// 图片 +adminApp.get('/api/images', (req, res) => { + const gid = parseInt(req.query.group_id); + if (gid) { + res.json(runQuery('SELECT * FROM images WHERE group_id=? ORDER BY id DESC', [gid])); } else { - images = runQuery('SELECT * FROM images ORDER BY id DESC'); + res.json(runQuery('SELECT * FROM images ORDER BY id DESC')); } - res.json(images); }); -// 图片启用/禁用API -app.put('/api/images/:id/enable', authMiddleware, (req, res) => { - const id = parseInt(req.params.id); - runExec('UPDATE images SET enabled = 1 WHERE id = ?', [id]); - res.json({ success: true }); +adminApp.post('/api/upload', authMiddleware, upload.single('image'), (req, res) => { + if (!req.file) return res.status(400).json({ error: '没有上传文件' }); + const group_id = parseInt(req.body.group_id); + if (!group_id) return res.status(400).json({ error: '缺少分组ID' }); + runExec('INSERT INTO images (group_id, filename, original_name, size) VALUES (?,?,?,?)', + [group_id, req.file.filename, req.file.originalname, req.file.size || 0]); + saveDB(); + const id = db.exec("SELECT last_insert_rowid()")[0].values[0][0]; + broadcastState(); + res.json({ id, filename: req.file.filename }); }); -app.put('/api/images/:id/disable', authMiddleware, (req, res) => { - const id = parseInt(req.params.id); - runExec('UPDATE images SET enabled = 0 WHERE id = ?', [id]); - res.json({ success: true }); +adminApp.put('/api/images/:id/enable', authMiddleware, (req, res) => { + runExec('UPDATE images SET enabled=1 WHERE id=?', [parseInt(req.params.id)]); + saveDB(); broadcastState(); res.json({ success: true }); }); -// 图片删除API -app.delete('/api/images/:id', authMiddleware, (req, res) => { +adminApp.put('/api/images/:id/disable', authMiddleware, (req, res) => { + runExec('UPDATE images SET enabled=0 WHERE id=?', [parseInt(req.params.id)]); + saveDB(); broadcastState(); res.json({ success: true }); +}); + +adminApp.delete('/api/images/:id', authMiddleware, (req, res) => { const id = parseInt(req.params.id); - const images = runQuery('SELECT filename FROM images WHERE id = ?', [id]); - if (images.length > 0) { - const filePath = path.join(__dirname, 'uploads', images[0].filename); - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + const imgs = runQuery('SELECT filename FROM images WHERE id=?', [id]); + if (imgs.length > 0) { + const fp = path.join(__dirname, 'uploads', imgs[0].filename); + if (fs.existsSync(fp)) fs.unlinkSync(fp); } - runExec('DELETE FROM images WHERE id = ?', [id]); - res.json({ success: true }); + runExec('DELETE FROM images WHERE id=?', [id]); + saveDB(); broadcastState(); res.json({ success: true }); }); -// 配置API -app.get('/api/config', (req, res) => { +// 配置 +adminApp.get('/api/config', (req, res) => { const rows = runQuery('SELECT * FROM config'); const config = {}; - rows.forEach(row => { config[row.key] = row.value; }); + rows.forEach(r => config[r.key] = r.value); res.json(config); }); -app.put('/api/config', authMiddleware, (req, res) => { - const { key, value } = req.body; - runExec('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', [key, value]); +adminApp.put('/api/config', authMiddleware, (req, res) => { + setConfig(req.body.key, req.body.value); + broadcastState(); res.json({ success: true }); }); -// 首页展示API -app.get('/api/display', (req, res) => { - const configRows = runQuery("SELECT value FROM config WHERE key = 'feature_enabled'"); - const featureEnabled = configRows.length > 0 && configRows[0].value === 'true'; - - if (!featureEnabled) { - return res.json({ enabled: false, groups: [] }); - } - - const groups = runQuery('SELECT * FROM groups WHERE enabled = 1 ORDER BY id ASC'); - const now = Math.floor(Date.now() / 1000); - - const result = groups.filter(group => { - // 展示时间为0则一直展示 - if (group.display_seconds === 0) return true; - // update_time为0则不展示(未设置过时间) - if (group.update_time === 0) return false; - // 计算剩余时间 - const remaining = group.update_time + group.display_seconds - now; - return remaining > 0; - }).map(group => { - const images = runQuery('SELECT * FROM images WHERE group_id = ? AND enabled = 1', [group.id]); - const remaining = group.update_time === 0 ? 0 : - Math.max(0, group.update_time + group.display_seconds - now); - return { - ...group, - remaining, - images - }; - }).filter(group => group.images.length > 0); - - res.json({ enabled: true, groups: result }); +// 重置轮播 +adminApp.post('/api/carousel/reset', authMiddleware, (req, res) => { + resetCarousel(); + res.json({ success: true }); }); -// 静态文件路由 -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); +// 路由 - 后台直接在根路径,未登录跳转登录页 +adminApp.get('/login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'login.html'))); +adminApp.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html'))); + +adminApp.listen(ADMIN_PORT, '0.0.0.0', () => { + console.log(`后台运行在 http://0.0.0.0:${ADMIN_PORT}`); }); -app.get('/login', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'login.html')); -}); - -app.get('/admin', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'admin.html')); -}); - -// 启动服务器 +// ========== 启动 ========== initDB().then(() => { - app.listen(PORT, '0.0.0.0', () => { - console.log(`服务器运行在 http://localhost:${PORT}`); - }); + startCarouselTimer(); + console.log('数据库初始化完成'); }).catch(err => { - console.error('数据库初始化失败:', err); + console.error('启动失败:', err); process.exit(1); });