Files
showpic/server.js
T

372 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const path = require('path');
const os = require('os');
const multer = require('multer');
const initSqlJs = require('sql.js');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const WebSocket = require('ws');
const ADMIN_PORT = 16888;
const FRONT_PORT = 80;
let db;
const DB_PATH = path.join(os.homedir(), '.showpic.db');
async function initDB() {
const SQL = await initSqlJs();
if (fs.existsSync(DB_PATH)) {
db = new SQL.Database(fs.readFileSync(DB_PATH));
} 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 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();
}
function saveDB() {
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
function runQuery(sql, params) {
const stmt = db.prepare(sql);
if (params && params.length > 0) stmt.bind(params);
const results = [];
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();
}
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) 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 });
// ========== 前台服务 (端口 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('/uploads', express.static(path.join(__dirname, 'uploads')));
// 路由 - 后台直接在根路径,未登录跳转登录页
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')));
// 静态文件(不自动serve index.html
adminApp.use(express.static(path.join(__dirname, 'public'), { index: false }));
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token || token !== 'admin-token') return res.status(401).json({ error: '未授权' });
next();
};
// 登录
adminApp.post('/api/login', (req, res) => {
const { username, password } = req.body;
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: '用户名或密码错误' });
}
});
// 修改密码
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 });
});
// 分组
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) VALUES (?, ?)', [name || '新分组', display_seconds || 30]);
saveDB();
const id = db.exec("SELECT last_insert_rowid()")[0].values[0][0];
broadcastState();
res.json({ id });
});
adminApp.put('/api/groups/:id', authMiddleware, (req, res) => {
const { name, display_seconds, enabled } = req.body;
runExec('UPDATE groups SET name=?, display_seconds=?, enabled=? WHERE id=?',
[name, display_seconds, enabled, parseInt(req.params.id)]);
saveDB();
broadcastState();
res.json({ success: true });
});
adminApp.put('/api/groups/:id/settime', authMiddleware, (req, res) => {
const { display_seconds } = req.body;
runExec('UPDATE groups SET display_seconds=? WHERE id=?', [display_seconds, parseInt(req.params.id)]);
saveDB();
broadcastState();
res.json({ success: true });
});
adminApp.delete('/api/groups/:id', authMiddleware, (req, res) => {
const id = parseInt(req.params.id);
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]);
saveDB();
resetCarousel();
res.json({ success: true });
});
// 图片
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 {
res.json(runQuery('SELECT * FROM images ORDER BY id DESC'));
}
});
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 });
});
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 });
});
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 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]);
saveDB(); broadcastState(); res.json({ success: true });
});
// 配置
adminApp.get('/api/config', (req, res) => {
const rows = runQuery('SELECT * FROM config');
const config = {};
rows.forEach(r => config[r.key] = r.value);
res.json(config);
});
adminApp.put('/api/config', authMiddleware, (req, res) => {
setConfig(req.body.key, req.body.value);
broadcastState();
res.json({ success: true });
});
// 重置轮播
adminApp.post('/api/carousel/reset', authMiddleware, (req, res) => {
resetCarousel();
res.json({ success: true });
});
// 路由 - 后台直接在根路径,未登录跳转登录页
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}`);
});
// ========== 启动 ==========
initDB().then(() => {
startCarouselTimer();
console.log('数据库初始化完成');
}).catch(err => {
console.error('启动失败:', err);
process.exit(1);
});