feat: poll/voting system with live results and bar charts
This commit is contained in:
@@ -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,
|
||||
|
||||
65
backend/routes/polls.js
Normal file
65
backend/routes/polls.js
Normal file
@@ -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;
|
||||
@@ -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}`));
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
<script src="js/pages/notifications.js"></script>
|
||||
<script src="js/pages/settings-page.js"></script>
|
||||
<script src="js/pages/export-page.js"></script>
|
||||
<script src="js/pages/polls.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 = `<span class="tm ts">${U.esc(u.game_name||u.username)}</span>
|
||||
<a href="#/dashboard" class="btn btn-p btn-sm"><i class="fas fa-chart-simple"></i> 控制台</a>
|
||||
<a href="#/polls" class="btn btn-o btn-sm"><i class="fas fa-poll"></i></a>
|
||||
${Auth.isAdmin() ? `<a href="#/unclaimed" class="btn btn-o btn-sm">待处理</a>`:''}
|
||||
<button class="btn btn-o btn-sm" onclick="App.logout()"><i class="fas fa-sign-out-alt"></i> 退出</button>`;
|
||||
} 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;
|
||||
|
||||
81
public/js/pages/polls.js
Normal file
81
public/js/pages/polls.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const PollsPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '投票';
|
||||
document.getElementById('page-actions').innerHTML = Auth.isOwner()
|
||||
? '<button class="btn btn-p btn-sm" onclick="PollsPage.showCreate()"><i class="fas fa-plus"></i> 创建投票</button>'
|
||||
: '';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
const polls = await API.get('/polls/active');
|
||||
ct.innerHTML = polls.length === 0
|
||||
? '<div class="empty"><i class="fas fa-poll"></i><p>暂无进行中的投票</p></div>'
|
||||
: polls.map(p => this.renderPoll(p)).join('');
|
||||
for (const p of polls) this.bindVote(p);
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
renderPoll(p) {
|
||||
const opts = p.options || [];
|
||||
const max = Math.max(1, ...Object.values(p.votes || {}));
|
||||
return `<div class="card" id="poll-${p.id}">
|
||||
<div class="card-h"><span>${U.esc(p.title)}</span><span class="ts tm">${p.total||0} 票</span></div>
|
||||
<div class="card-b">
|
||||
${p.description?`<p class="tm mb-4">${U.esc(p.description)}</p>`:''}
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
${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 `<div class="poll-opt" style="position:relative;overflow:hidden;border:1px solid var(--g200);border-radius:var(--r);cursor:pointer" data-poll="${p.id}" data-idx="${i}">
|
||||
<div style="position:absolute;top:0;left:0;bottom:0;width:${pct}%;background:${my?'var(--pl)':'var(--g2)'};transition:width .3s;z-index:0"></div>
|
||||
<div style="position:relative;z-index:1;display:flex;justify-content:space-between;align-items:center;padding:10px 14px;font-size:13px">
|
||||
<span>${my?'<i class="fas fa-check" style="color:var(--p)"></i> ':''}${U.esc(o)}</span>
|
||||
<span class="fb">${v}票 ${pct}%</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
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('创建投票', `
|
||||
<form id="poll-form">
|
||||
<div class="form-group"><label>标题 *</label><input id="pl-title" required></div>
|
||||
<div class="form-group"><label>描述</label><textarea id="pl-desc" rows="2"></textarea></div>
|
||||
<div class="form-group"><label>选项 (每行一个,至少2个) *</label><textarea id="pl-opts" rows="4" placeholder="选项A 选项B 选项C" required></textarea></div>
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">创建</button></div>
|
||||
</form>
|
||||
`);
|
||||
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); }
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user