feat: feature voting (PCL-style), poll owner edit/delete, blind polls for all
This commit is contained in:
@@ -200,6 +200,23 @@ async function initSchema(connection) {
|
|||||||
INDEX idx_poll (poll_id)
|
INDEX idx_poll (poll_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) 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 (
|
await createIfNotExists('audit_logs', `CREATE TABLE audit_logs (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT,
|
user_id INT,
|
||||||
|
|||||||
51
backend/routes/features.js
Normal file
51
backend/routes/features.js
Normal file
@@ -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;
|
||||||
@@ -4,9 +4,10 @@ const { authenticate, requireRole } = require('../middleware/auth');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', authenticate, async (req, res) => {
|
router.get('/:id', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
const polls = await query('SELECT id, title, description, active, created_at FROM polls ORDER BY created_at DESC');
|
const p = await getRow('SELECT * FROM polls WHERE id = ?', [req.params.id]);
|
||||||
res.json(polls);
|
if (!p) return res.status(404).json({ error: '不存在' });
|
||||||
|
res.json(p);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/active', authenticate, async (req, res) => {
|
router.get('/active', authenticate, async (req, res) => {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ if (isInstalled()) {
|
|||||||
app.use('/api/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads'));
|
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/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/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.use('/api/external', require('./routes/external'));
|
||||||
|
|
||||||
app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
<script src="js/pages/settings-page.js"></script>
|
<script src="js/pages/settings-page.js"></script>
|
||||||
<script src="js/pages/export-page.js"></script>
|
<script src="js/pages/export-page.js"></script>
|
||||||
<script src="js/pages/polls.js"></script>
|
<script src="js/pages/polls.js"></script>
|
||||||
|
<script src="js/pages/features.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const App = {
|
|||||||
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner','admin'] },
|
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner','admin'] },
|
||||||
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
|
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
|
||||||
{ id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner','admin','player'] },
|
{ id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner','admin','player'] },
|
||||||
|
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner','admin','player'] },
|
||||||
],
|
],
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -78,6 +79,7 @@ const App = {
|
|||||||
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
||||||
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
||||||
case 'polls': this.renderMain('投票', PollsPage, param); break;
|
case 'polls': this.renderMain('投票', PollsPage, param); break;
|
||||||
|
case 'features': this.renderMain('更新内容', FeaturesPage, param); break;
|
||||||
default: this.renderMain('控制台', Dashboard, param);
|
default: this.renderMain('控制台', Dashboard, param);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -148,6 +150,7 @@ window.NotificationsPage = NotificationsPage;
|
|||||||
window.SettingsPage = SettingsPage;
|
window.SettingsPage = SettingsPage;
|
||||||
window.ExportPage = ExportPage;
|
window.ExportPage = ExportPage;
|
||||||
window.PollsPage = PollsPage;
|
window.PollsPage = PollsPage;
|
||||||
|
window.FeaturesPage = FeaturesPage;
|
||||||
window.LoginPage = LoginPage;
|
window.LoginPage = LoginPage;
|
||||||
window.RegisterPage = RegisterPage;
|
window.RegisterPage = RegisterPage;
|
||||||
window.HomePage = HomePage;
|
window.HomePage = HomePage;
|
||||||
|
|||||||
89
public/js/pages/features.js
Normal file
89
public/js/pages/features.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const FeaturesPage = {
|
||||||
|
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="FeaturesPage.showCreate()"><i class="fas fa-plus"></i> 添加项目</button>'
|
||||||
|
: '';
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
try {
|
||||||
|
const items = await API.get('/features');
|
||||||
|
ct.innerHTML = items.length === 0
|
||||||
|
? '<div class="empty"><i class="fas fa-lightbulb"></i><p>暂无更新项目</p></div>'
|
||||||
|
: items.map(i => this.renderItem(i)).join('');
|
||||||
|
for (const i of items) if (Auth.logged() || Auth.logged()) this.bindVote(i);
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
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 `<div class="card" id="feat-${i.id}">
|
||||||
|
<div class="card-h">
|
||||||
|
<span>${U.esc(i.title)}</span>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<span style="font-size:11px;color:${statusColor}">● ${statusLabel}</span>
|
||||||
|
${Auth.isOwner() ? `<button class="btn btn-o btn-sm" onclick="FeaturesPage.showEdit(${i.id},'${U.escJs(i.title)}','${U.escJs(i.description||'')}','${i.status}')"><i class="fas fa-edit"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-b">
|
||||||
|
${i.description ? `<p class="tm mb-4">${U.esc(i.description)}</p>` : ''}
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<button class="btn btn-${i.my_vote?'s':'o'} btn-sm feat-vote" data-id="${i.id}">
|
||||||
|
<i class="fas fa-chevron-up"></i> ${i.votes_count || 0}
|
||||||
|
</button>
|
||||||
|
<span class="ts tm">${i.my_vote ? '已投票 (点击取消)' : '点击投票'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
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('添加项目', `
|
||||||
|
<form id="feat-form">
|
||||||
|
<div class="form-group"><label>标题 *</label><input id="ft-title" required></div>
|
||||||
|
<div class="form-group"><label>描述</label><textarea id="ft-desc" rows="2"></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('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('编辑', `
|
||||||
|
<form id="fe-edit">
|
||||||
|
<div class="form-group"><label>标题</label><input id="fe-title" value="${title}"></div>
|
||||||
|
<div class="form-group"><label>描述</label><textarea id="fe-desc" rows="2">${desc}</textarea></div>
|
||||||
|
<div class="form-group"><label>状态</label><select id="fe-status">
|
||||||
|
<option value="pending" ${status==='pending'?'selected':''}>待定</option>
|
||||||
|
<option value="planned" ${status==='planned'?'selected':''}>计划中</option>
|
||||||
|
<option value="done" ${status==='done'?'selected':''}>已完成</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="button" class="btn btn-d" onclick="FeaturesPage.del(${id})"><i class="fas fa-trash"></i></button><button type="submit" class="btn btn-p">保存</button></div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
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);}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -23,9 +23,11 @@ const PollsPage = {
|
|||||||
renderPoll(p) {
|
renderPoll(p) {
|
||||||
const opts = p.options || [];
|
const opts = p.options || [];
|
||||||
const max = Math.max(1, ...Object.values(p.votes || {}));
|
const max = Math.max(1, ...Object.values(p.votes || {}));
|
||||||
const showResult = Auth.isOwner() || !p.voted;
|
const showResult = !p.voted;
|
||||||
return `<div class="card" id="poll-${p.id}">
|
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-h"><span>${U.esc(p.title)}</span><span class="ts tm">${p.total||0} 票</span>
|
||||||
|
${Auth.isOwner() ? `<span><button class="btn btn-o btn-sm" onclick="PollsPage.showEdit(${p.id})"><i class="fas fa-edit"></i></button></span>` : ''}
|
||||||
|
</div>
|
||||||
<div class="card-b">
|
<div class="card-b">
|
||||||
${p.description?`<p class="tm mb-4">${U.esc(p.description)}</p>`:''}
|
${p.description?`<p class="tm mb-4">${U.esc(p.description)}</p>`:''}
|
||||||
${p.voted && !showResult ? `<div class="alert alert-s"><i class="fas fa-check-circle"></i> 已投票,结果仅在投票结束后向服主公开</div>` : ''}
|
${p.voted && !showResult ? `<div class="alert alert-s"><i class="fas fa-check-circle"></i> 已投票,结果仅在投票结束后向服主公开</div>` : ''}
|
||||||
@@ -74,11 +76,33 @@ const PollsPage = {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const opts = document.getElementById('pl-opts').value.split('\n').map(s=>s.trim()).filter(s=>s);
|
const opts = document.getElementById('pl-opts').value.split('\n').map(s=>s.trim()).filter(s=>s);
|
||||||
if (opts.length < 2) return alert('至少需要2个选项');
|
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('编辑投票', '<div class="loading"><i class="fas fa-spinner"></i></div>');
|
||||||
|
const p = await API.get('/polls/' + id);
|
||||||
|
document.getElementById('modal-body').innerHTML = `
|
||||||
|
<form id="pe-form">
|
||||||
|
<div class="form-group"><label>状态</label><select id="pe-active"><option value="1" ${p.active?'selected':''}>启用</option><option value="0" ${!p.active?'selected':''}>关闭</option></select></div>
|
||||||
|
<div class="form-group"><label>标题</label><input id="pe-title" value="${U.esc(p.title)}"></div>
|
||||||
|
<div class="form-group"><label>选项 (每行一个)</label><textarea id="pe-opts" rows="4">${U.esc((JSON.parse(p.options||'[]')).join('\n'))}</textarea></div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="button" class="btn btn-d" onclick="PollsPage.deletePoll(${id})"><i class="fas fa-trash"></i></button><button type="submit" class="btn btn-p">保存</button></div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
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 {
|
try {
|
||||||
await API.post('/polls', { title: document.getElementById('pl-title').value, description: document.getElementById('pl-desc').value, options: opts });
|
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();
|
App.closeModal(); this.load();
|
||||||
this.load();
|
|
||||||
} catch (ex) { alert(ex.message); }
|
} 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);}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user