From ee2f33c61f6099c5f719d5d0fd8fce8989faea48 Mon Sep 17 00:00:00 2001 From: canglan Date: Tue, 14 Jul 2026 01:17:56 +0800 Subject: [PATCH] feat: feature voting (PCL-style), poll owner edit/delete, blind polls for all --- backend/db.js | 17 +++++++ backend/routes/features.js | 51 +++++++++++++++++++++ backend/routes/polls.js | 7 +-- backend/server.js | 1 + public/index.html | 1 + public/js/app.js | 3 ++ public/js/pages/features.js | 89 +++++++++++++++++++++++++++++++++++++ public/js/pages/polls.js | 34 +++++++++++--- 8 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 backend/routes/features.js create mode 100644 public/js/pages/features.js diff --git a/backend/db.js b/backend/db.js index 4417ceb..2bcc470 100644 --- a/backend/db.js +++ b/backend/db.js @@ -200,6 +200,23 @@ async function initSchema(connection) { INDEX idx_poll (poll_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await createIfNotExists('feature_items', `CREATE TABLE feature_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT, + status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + + await createIfNotExists('feature_votes', `CREATE TABLE feature_votes ( + id INT AUTO_INCREMENT PRIMARY KEY, + item_id INT NOT NULL, + user_id INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_item_user (item_id, user_id), + INDEX idx_item (item_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/features.js b/backend/routes/features.js new file mode 100644 index 0000000..d92f3a9 --- /dev/null +++ b/backend/routes/features.js @@ -0,0 +1,51 @@ +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 items = await query(`SELECT fi.*, + (SELECT COUNT(*) FROM feature_votes WHERE item_id = fi.id) as votes_count, + (SELECT COUNT(*) FROM feature_votes WHERE item_id = fi.id AND user_id = ?) as my_vote + FROM feature_items fi ORDER BY votes_count DESC, fi.created_at DESC`, [req.user.id]); + res.json(items); +}); + +router.post('/', authenticate, requireRole('owner'), async (req, res) => { + const { title, description } = req.body; + if (!title) return res.status(400).json({ error: '标题为必填' }); + const r = await query('INSERT INTO feature_items(title, description) VALUES (?,?)', [title, description||'']); + res.status(201).json({ id: r.insertId }); +}); + +router.put('/:id', authenticate, requireRole('owner'), async (req, res) => { + const fields = {}; + if (req.body.title) fields.title = req.body.title; + if (req.body.description !== undefined) fields.description = req.body.description; + if (req.body.status) fields.status = req.body.status; + if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' }); + const sets = Object.keys(fields).map(k => `${k} = ?`).join(', '); + await query(`UPDATE feature_items 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 feature_votes WHERE item_id = ?', [req.params.id]); + await query('DELETE FROM feature_items WHERE id = ?', [req.params.id]); + res.json({ message: '已删除' }); +}); + +router.post('/:id/vote', authenticate, async (req, res) => { + const item = await getRow('SELECT id FROM feature_items WHERE id = ?', [req.params.id]); + if (!item) return res.status(404).json({ error: '不存在' }); + const voted = await getRow('SELECT id FROM feature_votes WHERE item_id = ? AND user_id = ?', [item.id, req.user.id]); + if (voted) { + await query('DELETE FROM feature_votes WHERE item_id = ? AND user_id = ?', [item.id, req.user.id]); + return res.json({ message: '已取消投票' }); + } + await query('INSERT INTO feature_votes(item_id, user_id) VALUES (?,?)', [item.id, req.user.id]); + res.json({ message: '投票成功' }); +}); + +module.exports = router; diff --git a/backend/routes/polls.js b/backend/routes/polls.js index 89c9df0..17aacce 100644 --- a/backend/routes/polls.js +++ b/backend/routes/polls.js @@ -4,9 +4,10 @@ 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('/: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: '不存在' }); + res.json(p); }); router.get('/active', authenticate, async (req, res) => { diff --git a/backend/server.js b/backend/server.js index 87a2529..ddb6b41 100644 --- a/backend/server.js +++ b/backend/server.js @@ -105,6 +105,7 @@ if (isInstalled()) { 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/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features')); 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 b6863a2..c3401fd 100644 --- a/public/index.html +++ b/public/index.html @@ -70,6 +70,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 98d662c..72aba60 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -10,6 +10,7 @@ const App = { { 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'] }, + { id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner','admin','player'] }, ], async init() { @@ -78,6 +79,7 @@ const App = { case 'settings': this.renderMain('系统设置', SettingsPage, param); break; case 'export': this.renderMain('数据导出', ExportPage, param); break; case 'polls': this.renderMain('投票', PollsPage, param); break; + case 'features': this.renderMain('更新内容', FeaturesPage, param); break; default: this.renderMain('控制台', Dashboard, param); } }, @@ -148,6 +150,7 @@ window.NotificationsPage = NotificationsPage; window.SettingsPage = SettingsPage; window.ExportPage = ExportPage; window.PollsPage = PollsPage; +window.FeaturesPage = FeaturesPage; window.LoginPage = LoginPage; window.RegisterPage = RegisterPage; window.HomePage = HomePage; diff --git a/public/js/pages/features.js b/public/js/pages/features.js new file mode 100644 index 0000000..4216b17 --- /dev/null +++ b/public/js/pages/features.js @@ -0,0 +1,89 @@ +const FeaturesPage = { + 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 items = await API.get('/features'); + ct.innerHTML = items.length === 0 + ? '

暂无更新项目

' + : items.map(i => this.renderItem(i)).join(''); + for (const i of items) if (Auth.logged() || Auth.logged()) this.bindVote(i); + } catch (e) { ct.innerHTML = `
${e.message}
`; } + }, + + renderItem(i) { + const statusColor = i.status === 'done' ? 'var(--s)' : i.status === 'planned' ? 'var(--p)' : 'var(--g5)'; + const statusLabel = i.status === 'done' ? '已完成' : i.status === 'planned' ? '计划中' : '待定'; + return `
+
+ ${U.esc(i.title)} +
+ ● ${statusLabel} + ${Auth.isOwner() ? `` : ''} +
+
+
+ ${i.description ? `

${U.esc(i.description)}

` : ''} +
+ + ${i.my_vote ? '已投票 (点击取消)' : '点击投票'} +
+
+
`; + }, + + bindVote(i) { + const card = document.getElementById('feat-'+i.id); + if (!card) return; + card.querySelector('.feat-vote').addEventListener('click', async () => { + try { await API.post(`/features/${i.id}/vote`); this.load(); } catch (ex) { alert(ex.message); } + }); + }, + + showCreate() { + U.modal('添加项目', ` +
+
+
+ +
+ `); + document.getElementById('feat-form').onsubmit = async e => { e.preventDefault(); + try { await API.post('/features', { title: document.getElementById('ft-title').value, description: document.getElementById('ft-desc').value }); App.closeModal(); this.load(); } catch(ex){alert(ex.message);} + }; + }, + + async showEdit(id, title, desc, status) { + U.modal('编辑', ` +
+
+
+
+ +
+ `); + document.getElementById('fe-edit').onsubmit = async e => { e.preventDefault(); + try { await API.put('/features/'+id, { title: document.getElementById('fe-title').value, description: document.getElementById('fe-desc').value, status: document.getElementById('fe-status').value }); App.closeModal(); this.load(); } catch(ex){alert(ex.message);} + }; + }, + + async del(id) { + if (!await U.confirm('确认删除?')) return; + try { await API.req('DELETE', '/features/'+id); App.closeModal(); this.load(); } catch(ex){alert(ex.message);} + } +}; diff --git a/public/js/pages/polls.js b/public/js/pages/polls.js index 63f02c8..6c3f51f 100644 --- a/public/js/pages/polls.js +++ b/public/js/pages/polls.js @@ -23,9 +23,11 @@ const PollsPage = { renderPoll(p) { const opts = p.options || []; const max = Math.max(1, ...Object.values(p.votes || {})); - const showResult = Auth.isOwner() || !p.voted; + const showResult = !p.voted; return `
-
${U.esc(p.title)}${p.total||0} 票
+
${U.esc(p.title)}${p.total||0} 票 + ${Auth.isOwner() ? `` : ''} +
${p.description?`

${U.esc(p.description)}

`:''} ${p.voted && !showResult ? `
已投票,结果仅在投票结束后向服主公开
` : ''} @@ -74,11 +76,33 @@ const PollsPage = { 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); } + }; + }, + + async showEdit(id) { + U.modal('编辑投票', '
'); + const p = await API.get('/polls/' + id); + document.getElementById('modal-body').innerHTML = ` +
+
+
+
+ +
+ `; + document.getElementById('pe-form').onsubmit = async e => { + e.preventDefault(); + const opts = document.getElementById('pe-opts').value.split('\n').map(s=>s.trim()).filter(s=>s); try { - await API.post('/polls', { title: document.getElementById('pl-title').value, description: document.getElementById('pl-desc').value, options: opts }); - App.closeModal(); - this.load(); + await API.put('/polls/'+id, { title: document.getElementById('pe-title').value, active: document.getElementById('pe-active').value==='1', options: opts.length>=2?opts:undefined }); + App.closeModal(); this.load(); } catch (ex) { alert(ex.message); } }; + }, + + async deletePoll(id) { + if (!await U.confirm('确认删除此投票?')) return; + try { await API.req('DELETE', '/polls/'+id); App.closeModal(); this.load(); } catch(ex){alert(ex.message);} } };