From 116fe89ac8f9d2a55f4d75a7e8afb828a6bbd97a Mon Sep 17 00:00:00 2001 From: canglan Date: Mon, 13 Jul 2026 21:37:16 +0800 Subject: [PATCH] feat: poll/voting system with live results and bar charts --- backend/db.js | 21 +++++++++++ backend/routes/polls.js | 65 ++++++++++++++++++++++++++++++++ backend/server.js | 1 + public/index.html | 1 + public/js/app.js | 4 ++ public/js/pages/polls.js | 81 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 backend/routes/polls.js create mode 100644 public/js/pages/polls.js diff --git a/backend/db.js b/backend/db.js index 4a9cd0d..4417ceb 100644 --- a/backend/db.js +++ b/backend/db.js @@ -179,6 +179,27 @@ async function initSchema(connection) { created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await createIfNotExists('polls', `CREATE TABLE polls ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT, + options JSON NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_active (active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + + await createIfNotExists('poll_votes', `CREATE TABLE poll_votes ( + id INT AUTO_INCREMENT PRIMARY KEY, + poll_id INT NOT NULL, + user_id INT NOT NULL, + option_index INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_poll_user (poll_id, user_id), + INDEX idx_poll (poll_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await createIfNotExists('audit_logs', `CREATE TABLE audit_logs ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, diff --git a/backend/routes/polls.js b/backend/routes/polls.js new file mode 100644 index 0000000..23088b3 --- /dev/null +++ b/backend/routes/polls.js @@ -0,0 +1,65 @@ +const express = require('express'); +const { query, getRow } = require('../db'); +const { authenticate, requireRole } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/', authenticate, async (req, res) => { + const polls = await query('SELECT id, title, description, active, created_at FROM polls ORDER BY created_at DESC'); + res.json(polls); +}); + +router.get('/active', authenticate, async (req, res) => { + const polls = await query('SELECT id, title, description, active, created_at FROM polls WHERE active = 1 ORDER BY created_at DESC'); + for (const p of polls) { + p.options = JSON.parse(p.options || '[]'); + const rows = await query('SELECT option_index, COUNT(*) as count FROM poll_votes WHERE poll_id = ? GROUP BY option_index', [p.id]); + p.votes = {}; + let total = 0; + for (const r of rows) { p.votes[r.option_index] = r.count; total += r.count; } + p.total = total; + const myVote = await getRow('SELECT option_index FROM poll_votes WHERE poll_id = ? AND user_id = ?', [p.id, req.user.id]); + p.my_vote = myVote ? myVote.option_index : null; + } + res.json(polls); +}); + +router.post('/', authenticate, requireRole('owner'), async (req, res) => { + const { title, description, options } = req.body; + if (!title || !options || !Array.isArray(options) || options.length < 2) return res.status(400).json({ error: '标题和至少2个选项为必填' }); + const r = await query('INSERT INTO polls(title, description, options, created_by) VALUES (?,?,?,?)', [title, description||'', JSON.stringify(options), req.user.id]); + res.status(201).json({ id: r.insertId, message: '创建成功' }); +}); + +router.put('/:id', authenticate, requireRole('owner'), async (req, res) => { + const p = await getRow('SELECT * FROM polls WHERE id = ?', [req.params.id]); + if (!p) return res.status(404).json({ error: '投票不存在' }); + const fields = {}; + if (req.body.title) fields.title = req.body.title; + if (req.body.description !== undefined) fields.description = req.body.description; + if (req.body.active !== undefined) fields.active = req.body.active ? 1 : 0; + if (req.body.options) fields.options = JSON.stringify(req.body.options); + if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' }); + const sets = Object.keys(fields).map(k => `${k} = ?`).join(', '); + await query(`UPDATE polls SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]); + res.json({ message: '更新成功' }); +}); + +router.delete('/:id', authenticate, requireRole('owner'), async (req, res) => { + await query('DELETE FROM poll_votes WHERE poll_id = ?', [req.params.id]); + await query('DELETE FROM polls WHERE id = ?', [req.params.id]); + res.json({ message: '已删除' }); +}); + +router.post('/:id/vote', authenticate, async (req, res) => { + const p = await getRow('SELECT * FROM polls WHERE id = ? AND active = 1', [req.params.id]); + if (!p) return res.status(404).json({ error: '投票不存在或已关闭' }); + const { option_index } = req.body; + if (option_index === undefined || option_index === null) return res.status(400).json({ error: '请选择选项' }); + const opts = JSON.parse(p.options || '[]'); + if (option_index < 0 || option_index >= opts.length) return res.status(400).json({ error: '无效选项' }); + await query('INSERT INTO poll_votes(poll_id, user_id, option_index) VALUES (?,?,?) ON DUPLICATE KEY UPDATE option_index = VALUES(option_index)', [req.params.id, req.user.id, option_index]); + res.json({ message: '投票成功' }); +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 710e2a6..87a2529 100644 --- a/backend/server.js +++ b/backend/server.js @@ -104,6 +104,7 @@ if (isInstalled()) { app.use('/api/export', methodGuard(['GET']), generalLimiter, require('./routes/export')); app.use('/api/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads')); app.use('/api/notifications', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/notifications')); + app.use('/api/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls')); app.use('/api/external', require('./routes/external')); app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); diff --git a/public/index.html b/public/index.html index 0bda9a2..b6863a2 100644 --- a/public/index.html +++ b/public/index.html @@ -69,6 +69,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 9ea2788..98d662c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -9,6 +9,7 @@ const App = { { id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner','admin'] }, { id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner','admin'] }, { id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] }, + { id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner','admin','player'] }, ], async init() { @@ -76,6 +77,7 @@ const App = { case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break; case 'settings': this.renderMain('系统设置', SettingsPage, param); break; case 'export': this.renderMain('数据导出', ExportPage, param); break; + case 'polls': this.renderMain('投票', PollsPage, param); break; default: this.renderMain('控制台', Dashboard, param); } }, @@ -98,6 +100,7 @@ const App = { const u = Auth.user(); el.innerHTML = `${U.esc(u.game_name||u.username)} 控制台 + ${Auth.isAdmin() ? `待处理`:''} `; } else { @@ -144,6 +147,7 @@ window.TemplatesPage = TemplatesPage; window.NotificationsPage = NotificationsPage; window.SettingsPage = SettingsPage; window.ExportPage = ExportPage; +window.PollsPage = PollsPage; window.LoginPage = LoginPage; window.RegisterPage = RegisterPage; window.HomePage = HomePage; diff --git a/public/js/pages/polls.js b/public/js/pages/polls.js new file mode 100644 index 0000000..af766be --- /dev/null +++ b/public/js/pages/polls.js @@ -0,0 +1,81 @@ +const PollsPage = { + async render() { return '
'; }, + + async mount() { + document.getElementById('page-title').textContent = '投票'; + document.getElementById('page-actions').innerHTML = Auth.isOwner() + ? '' + : ''; + await this.load(); + }, + + async load() { + const ct = document.getElementById('page-content'); + try { + const polls = await API.get('/polls/active'); + ct.innerHTML = polls.length === 0 + ? '

暂无进行中的投票

' + : polls.map(p => this.renderPoll(p)).join(''); + for (const p of polls) this.bindVote(p); + } catch (e) { ct.innerHTML = `
${e.message}
`; } + }, + + renderPoll(p) { + const opts = p.options || []; + const max = Math.max(1, ...Object.values(p.votes || {})); + return `
+
${U.esc(p.title)}${p.total||0} 票
+
+ ${p.description?`

${U.esc(p.description)}

`:''} +
+ ${opts.map((o,i) => { + const v = p.votes?.[i] || 0; + const pct = p.total ? Math.round(v/p.total*100) : 0; + const my = p.my_vote === i; + return `
+
+
+ ${my?' ':''}${U.esc(o)} + ${v}票 ${pct}% +
+
`; + }).join('')} +
+
+
`; + }, + + bindVote(p) { + const card = document.getElementById('poll-'+p.id); + if (!card) return; + card.querySelectorAll('.poll-opt').forEach(el => { + el.addEventListener('click', async () => { + try { + await API.post(`/polls/${p.id}/vote`, { option_index: parseInt(el.dataset.idx) }); + this.load(); + } catch (ex) { alert(ex.message); } + }); + }); + }, + + showCreate() { + U.modal('创建投票', ` +
+
+
+
+ +
+ `); + document.getElementById('poll-form').onsubmit = async e => { + e.preventDefault(); + const opts = document.getElementById('pl-opts').value.split('\n').map(s=>s.trim()).filter(s=>s); + if (opts.length < 2) return alert('至少需要2个选项'); + try { + await API.post('/polls', { title: document.getElementById('pl-title').value, description: document.getElementById('pl-desc').value, options: opts }); + App.closeModal(); + this.load(); + } catch (ex) { alert(ex.message); } + }; + } +};