feat: poll groups/servers, timed polls, features sort+filter by group
This commit is contained in:
@@ -184,10 +184,15 @@ async function initSchema(connection) {
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
options JSON NOT NULL,
|
||||
group_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
server_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_active (active)
|
||||
INDEX idx_active (active),
|
||||
INDEX idx_group_server (group_name, server_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('poll_votes', `CREATE TABLE poll_votes (
|
||||
@@ -204,6 +209,8 @@ async function initSchema(connection) {
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
group_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
server_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
@@ -217,6 +224,13 @@ async function initSchema(connection) {
|
||||
INDEX idx_item (item_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('server_groups', `CREATE TABLE server_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
group_name VARCHAR(100) NOT NULL,
|
||||
server_name VARCHAR(100) NOT NULL,
|
||||
UNIQUE KEY uk_group_server (group_name, server_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('audit_logs', `CREATE TABLE audit_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
|
||||
59
backend/routes/features.js
Normal file
59
backend/routes/features.js
Normal file
@@ -0,0 +1,59 @@
|
||||
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 { group_name, server_name, sort } = req.query;
|
||||
let orderClause = 'ORDER BY fi.created_at DESC';
|
||||
if (sort === 'votes') orderClause = 'ORDER BY votes_count DESC, fi.created_at DESC';
|
||||
|
||||
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
|
||||
WHERE (fi.group_name = ? OR ? = '') AND (fi.server_name = ? OR ? = '')
|
||||
${orderClause}`, [req.user.id, group_name||'', group_name||'', server_name||'', server_name||'']);
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
router.post('/', authenticate, requireRole('owner'), async (req, res) => {
|
||||
const { title, description, group_name, server_name } = req.body;
|
||||
if (!title) return res.status(400).json({ error: '标题为必填' });
|
||||
await query('INSERT INTO feature_items(title, description, group_name, server_name) VALUES (?,?,?,?)', [title, description||'', group_name||'', server_name||'']);
|
||||
res.status(201).json({ id: 0 });
|
||||
});
|
||||
|
||||
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 (req.body.group_name) fields.group_name = req.body.group_name;
|
||||
if (req.body.server_name) fields.server_name = req.body.server_name;
|
||||
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;
|
||||
107
backend/routes/polls.js
Normal file
107
backend/routes/polls.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const express = require('express');
|
||||
const { query, getRow } = require('../db');
|
||||
const { authenticate, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/groups', authenticate, async (req, res) => {
|
||||
res.json(await query('SELECT * FROM server_groups ORDER BY group_name, server_name'));
|
||||
});
|
||||
|
||||
router.post('/groups', authenticate, requireRole('owner'), async (req, res) => {
|
||||
const { group_name, server_name } = req.body;
|
||||
if (!group_name || !server_name) return res.status(400).json({ error: '分组名和子服名为必填' });
|
||||
await query('INSERT IGNORE INTO server_groups(group_name, server_name) VALUES (?,?)', [group_name, server_name]);
|
||||
res.status(201).json({ message: '已添加' });
|
||||
});
|
||||
|
||||
router.delete('/groups/:id', authenticate, requireRole('owner'), async (req, res) => {
|
||||
await query('DELETE FROM server_groups WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: '已删除' });
|
||||
});
|
||||
|
||||
router.get('/active', authenticate, async (req, res) => {
|
||||
const groups = await query('SELECT DISTINCT group_name, server_name FROM server_groups ORDER BY group_name, server_name');
|
||||
const result = {};
|
||||
for (const g of groups) {
|
||||
const polls = await query(`SELECT id, title, description, group_name, server_name, start_time, end_time, active, created_at FROM polls
|
||||
WHERE active = 1 AND group_name = ? AND server_name = ? AND (end_time IS NULL OR end_time > NOW())
|
||||
ORDER BY created_at DESC LIMIT 1`, [g.group_name, g.server_name]);
|
||||
if (polls.length === 0) continue;
|
||||
const p = polls[0];
|
||||
p.options = JSON.parse(p.options || '[]');
|
||||
const rows = await query(`SELECT pv.option_index,
|
||||
SUM(CASE WHEN u.role IN ('owner','admin') THEN 1.5 ELSE 1 END) as total
|
||||
FROM poll_votes pv JOIN users u ON pv.user_id = u.id
|
||||
WHERE pv.poll_id = ? GROUP BY pv.option_index`, [p.id]);
|
||||
p.votes = {};
|
||||
let total = 0;
|
||||
for (const r of rows) { p.votes[r.option_index] = Math.round(r.total * 10) / 10; total += r.total; }
|
||||
p.total = Math.round(total * 10) / 10;
|
||||
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;
|
||||
p.voted = !!myVote;
|
||||
if (!result[g.group_name]) result[g.group_name] = [];
|
||||
result[g.group_name].push({ ...p, server_name: g.server_name });
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
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.post('/', authenticate, requireRole('owner'), async (req, res) => {
|
||||
const { title, description, options, group_name, server_name, start_time, end_time } = req.body;
|
||||
if (!title || !options || !Array.isArray(options) || options.length < 2) return res.status(400).json({ error: '标题和至少2个选项为必填' });
|
||||
if (!group_name || !server_name) return res.status(400).json({ error: '请选择分组和子服' });
|
||||
|
||||
const existing = await getRow(`SELECT id FROM polls WHERE active = 1 AND group_name = ? AND server_name = ? AND (end_time IS NULL OR end_time > NOW())`, [group_name, server_name]);
|
||||
if (existing) return res.status(400).json({ error: '该子服已有进行中的投票' });
|
||||
|
||||
await query(`INSERT INTO polls(title, description, options, group_name, server_name, start_time, end_time, created_by) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[title, description||'', JSON.stringify(options), group_name, server_name, start_time||null, end_time||null, req.user.id]);
|
||||
res.status(201).json({ 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 (req.body.start_time !== undefined) fields.start_time = req.body.start_time || null;
|
||||
if (req.body.end_time !== undefined) fields.end_time = req.body.end_time || null;
|
||||
if (req.body.group_name) fields.group_name = req.body.group_name;
|
||||
if (req.body.server_name) fields.server_name = req.body.server_name;
|
||||
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: '投票不存在或已关闭' });
|
||||
if (p.end_time && new Date(p.end_time) < new Date()) return res.status(400).json({ error: '投票已结束' });
|
||||
const voted = await getRow('SELECT id FROM poll_votes WHERE poll_id = ? AND user_id = ?', [p.id, req.user.id]);
|
||||
if (voted) return res.status(400).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 (?,?,?)', [req.params.id, req.user.id, option_index]);
|
||||
res.json({ message: '投票成功' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -104,6 +104,8 @@ 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/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}`));
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
<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/pages/features.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,8 @@ 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'] },
|
||||
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner','admin','player'] },
|
||||
],
|
||||
|
||||
async init() {
|
||||
@@ -76,6 +78,8 @@ 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;
|
||||
case 'features': this.renderMain('更新内容', FeaturesPage, param); break;
|
||||
default: this.renderMain('控制台', Dashboard, param);
|
||||
}
|
||||
},
|
||||
@@ -98,6 +102,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 +149,8 @@ window.TemplatesPage = TemplatesPage;
|
||||
window.NotificationsPage = NotificationsPage;
|
||||
window.SettingsPage = SettingsPage;
|
||||
window.ExportPage = ExportPage;
|
||||
window.PollsPage = PollsPage;
|
||||
window.FeaturesPage = FeaturesPage;
|
||||
window.LoginPage = LoginPage;
|
||||
window.RegisterPage = RegisterPage;
|
||||
window.HomePage = HomePage;
|
||||
|
||||
140
public/js/pages/features.js
Normal file
140
public/js/pages/features.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const FeaturesPage = {
|
||||
sort: 'time',
|
||||
filterGroup: '',
|
||||
filterServer: '',
|
||||
|
||||
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>'
|
||||
: '';
|
||||
this.renderLayout();
|
||||
await this.load();
|
||||
},
|
||||
|
||||
renderLayout() {
|
||||
const ct = document.getElementById('page-content');
|
||||
ct.innerHTML = `<div id="feat-filters" style="display:flex;gap:10px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||||
<select id="feat-group" onchange="FeaturesPage.setFilter()"><option value="">全部分组</option></select>
|
||||
<select id="feat-server" onchange="FeaturesPage.setFilter()"><option value="">全部子服</option></select>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn btn-o btn-sm ${this.sort==='time'?'active':''}" id="sort-time" onclick="FeaturesPage.setSort('time')">按时间</button>
|
||||
<button class="btn btn-o btn-sm ${this.sort==='votes'?'active':''}" id="sort-votes" onclick="FeaturesPage.setSort('votes')">按热度</button>
|
||||
</div>
|
||||
<div id="feat-list"></div>`;
|
||||
this.loadGroupOptions();
|
||||
},
|
||||
|
||||
async loadGroupOptions() {
|
||||
try {
|
||||
const groups = await API.get('/polls/groups');
|
||||
const groupSel = document.getElementById('feat-group');
|
||||
const serverSel = document.getElementById('feat-server');
|
||||
const uniqueGroups = [...new Set(groups.map(g => g.group_name))];
|
||||
groupSel.innerHTML = '<option value="">全部分组</option>' + uniqueGroups.map(g => `<option value="${g}" ${this.filterGroup===g?'selected':''}>${g}</option>`).join('');
|
||||
const servers = this.filterGroup ? groups.filter(g => g.group_name === this.filterGroup) : groups;
|
||||
const uniqueServers = [...new Set(servers.map(g => g.server_name))];
|
||||
serverSel.innerHTML = '<option value="">全部子服</option>' + uniqueServers.map(s => `<option value="${s}" ${this.filterServer===s?'selected':''}>${s}</option>`).join('');
|
||||
} catch {}
|
||||
},
|
||||
|
||||
setFilter() {
|
||||
this.filterGroup = document.getElementById('feat-group').value;
|
||||
this.filterServer = document.getElementById('feat-server').value;
|
||||
this.loadGroupOptions();
|
||||
this.load();
|
||||
},
|
||||
|
||||
setSort(s) {
|
||||
this.sort = s;
|
||||
document.getElementById('sort-time').className = 'btn btn-o btn-sm ' + (s==='time'?'btn-p':'');
|
||||
document.getElementById('sort-votes').className = 'btn btn-o btn-sm ' + (s==='votes'?'btn-p':'');
|
||||
this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('feat-list');
|
||||
U.loading(ct);
|
||||
try {
|
||||
const params = new URLSearchParams({ sort: this.sort, group_name: this.filterGroup, server_name: this.filterServer });
|
||||
const items = await API.get('/features?' + params);
|
||||
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()) this.bindVote(i);
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
renderItem(i) {
|
||||
const sc = i.status==='done'?'var(--s)':i.status==='planned'?'var(--p)':'var(--g5)';
|
||||
const sl = i.status==='done'?'已完成':i.status==='planned'?'计划中':'待定';
|
||||
return `<div class="card" id="feat-${i.id}">
|
||||
<div class="card-h">
|
||||
<span>${U.esc(i.title)} ${i.group_name ? `<span class="ts tm">— ${U.esc(i.group_name)}/${U.esc(i.server_name)}</span>` : ''}</span>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span style="font-size:11px;color:${sc}">● ${sl}</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}','${U.escJs(i.group_name||'')}','${U.escJs(i.server_name||'')}')"><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); }
|
||||
});
|
||||
},
|
||||
|
||||
async loadGroupsForForm() { try { return await API.get('/polls/groups'); } catch { return []; } },
|
||||
|
||||
async showCreate() {
|
||||
const groups = await this.loadGroupsForForm();
|
||||
const opts = groups.map(g => `<option value="${g.group_name}::${g.server_name}">${g.group_name} / ${g.server_name}</option>`).join('');
|
||||
U.modal('添加项目', `
|
||||
<form id="feat-form">
|
||||
<div class="grid-2"><div class="form-group"><label>标题 *</label><input id="ft-title" required></div>
|
||||
<div class="form-group"><label>分组/子服</label><select id="ft-gs"><option value="::">无</option>${opts}</select></div></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();
|
||||
const gs = document.getElementById('ft-gs').value;
|
||||
const [group_name, server_name] = gs === '::' ? ['',''] : gs.split('::');
|
||||
try { await API.post('/features', { title: document.getElementById('ft-title').value, description: document.getElementById('ft-desc').value, group_name, server_name }); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
async showEdit(id, title, desc, status, gn, sn) {
|
||||
const groups = await this.loadGroupsForForm();
|
||||
const opts = groups.map(g => `<option value="${g.group_name}::${g.server_name}" ${gn===g.group_name&&sn===g.server_name?'selected':''}>${g.group_name} / ${g.server_name}</option>`).join('');
|
||||
U.modal('编辑', `
|
||||
<form id="fe-edit">
|
||||
<div class="form-group"><label>标题</label><input id="fe-title" value="${title}"></div>
|
||||
<div class="grid-2"><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="form-group"><label>分组/子服</label><select id="fe-gs"><option value="::">无</option>${opts}</select></div></div>
|
||||
<div class="form-group"><label>描述</label><textarea id="fe-desc" rows="2">${desc}</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="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();
|
||||
const gs = document.getElementById('fe-gs').value;
|
||||
const [group_name, server_name] = gs === '::' ? ['',''] : gs.split('::');
|
||||
try { await API.put('/features/'+id, { title: document.getElementById('fe-title').value, description: document.getElementById('fe-desc').value, status: document.getElementById('fe-status').value, group_name, server_name }); 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);} }
|
||||
};
|
||||
142
public/js/pages/polls.js
Normal file
142
public/js/pages/polls.js
Normal file
@@ -0,0 +1,142 @@
|
||||
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><button class="btn btn-o btn-sm" onclick="PollsPage.showGroupMgr()"><i class="fas fa-cog"></i> 分组</button>'
|
||||
: '';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
const groups = await API.get('/polls/active');
|
||||
const entries = Object.entries(groups);
|
||||
if (entries.length === 0) {
|
||||
ct.innerHTML = '<div class="empty"><i class="fas fa-poll"></i><p>暂无进行中的投票</p></div>';
|
||||
return;
|
||||
}
|
||||
ct.innerHTML = entries.map(([group, polls]) => `
|
||||
<div style="margin-bottom:24px">
|
||||
<h3 style="font-size:16px;font-weight:700;margin-bottom:12px;color:var(--p)"><i class="fas fa-server"></i> ${U.esc(group)}</h3>
|
||||
${polls.map(p => this.renderPoll(p)).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
for (const [group, polls] of entries) 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 timed = p.start_time || p.end_time;
|
||||
return `<div class="card" id="poll-${p.id}">
|
||||
<div class="card-h"><span>${U.esc(p.server_name)} — ${U.esc(p.title)}</span>
|
||||
<span style="display:flex;gap:8px;align-items:center">
|
||||
${timed ? `<span class="ts tm">${p.start_time?'起: '+U.date(p.start_time):''} ${p.end_time?'至: '+U.date(p.end_time):''}</span>`:''}
|
||||
<span class="ts tm">${p.total||0} 票</span>
|
||||
${Auth.isOwner() ? `<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">
|
||||
${p.description?`<p class="tm mb-4">${U.esc(p.description)}</p>`:''}
|
||||
${p.voted ? `<div class="alert alert-s"><i class="fas fa-check-circle"></i> 已投票</div>` : ''}
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
${opts.map((o,i) => {
|
||||
const v = p.voted ? 0 : (p.votes?.[i] || 0);
|
||||
const pct = p.voted ? 0 : (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);${p.voted?'cursor:default':'cursor:pointer'}" data-poll="${p.id}" data-idx="${i}">
|
||||
${!p.voted ? `<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>
|
||||
${!p.voted ? `<span class="fb">${v}票 ${pct}%</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
bindVote(p) {
|
||||
if (p.voted) return;
|
||||
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() {
|
||||
this.loadGroups().then(groups => {
|
||||
const opts = groups.map(g => `<option value="${g.group_name}::${g.server_name}">${g.group_name} / ${g.server_name}</option>`).join('');
|
||||
U.modal('创建投票', `
|
||||
<form id="poll-form">
|
||||
<div class="grid-2"><div class="form-group"><label>分组/子服 *</label><select id="pl-gs"><option value="">请选择</option>${opts}</select></div>
|
||||
<div class="form-group"><label>标题 *</label><input id="pl-title" required></div></div>
|
||||
<div class="form-group"><label>描述</label><textarea id="pl-desc" rows="2"></textarea></div>
|
||||
<div class="grid-2"><div class="form-group"><label>开始时间(可选)</label><input type="datetime-local" id="pl-start"></div>
|
||||
<div class="form-group"><label>结束时间(可选)</label><input type="datetime-local" id="pl-end"></div></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 [group_name, server_name] = document.getElementById('pl-gs').value.split('::');
|
||||
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, group_name, server_name, start_time: document.getElementById('pl-start').value||null, end_time: document.getElementById('pl-end').value||null });
|
||||
App.closeModal(); this.load();
|
||||
} catch (ex) { alert(ex.message); }
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async loadGroups() { try { return await API.get('/polls/groups'); } catch { return []; } },
|
||||
|
||||
showGroupMgr() {
|
||||
this.loadGroups().then(groups => {
|
||||
const list = groups.map(g => `<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--g200)">
|
||||
<span>${U.esc(g.group_name)} / ${U.esc(g.server_name)}</span>
|
||||
<button class="btn btn-d btn-sm" onclick="PollsPage.delGroup(${g.id})"><i class="fas fa-trash"></i></button>
|
||||
</div>`).join('');
|
||||
U.modal('管理分组', `
|
||||
<form id="gm-form"><div class="grid-2"><div class="form-group"><label>分组名</label><input id="gm-group" required></div><div class="form-group"><label>子服名</label><input id="gm-server" required></div></div>
|
||||
<button type="submit" class="btn btn-p btn-sm mb-4"><i class="fas fa-plus"></i> 添加</button></form>
|
||||
<div>${list||'<p class="tm">暂无</p>'}</div>
|
||||
`);
|
||||
document.getElementById('gm-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.post('/polls/groups', { group_name: document.getElementById('gm-group').value, server_name: document.getElementById('gm-server').value }); App.closeModal(); this.showGroupMgr(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async delGroup(id) { try { await API.req('DELETE', '/polls/groups/'+id); App.closeModal(); this.showGroupMgr(); } catch(ex){alert(ex.message);} },
|
||||
|
||||
async showEdit(id) {
|
||||
const p = await API.get('/polls/'+id);
|
||||
document.getElementById('modal-title').textContent = '编辑投票';
|
||||
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('modal-overlay').classList.remove('hidden');
|
||||
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.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);} }
|
||||
};
|
||||
Reference in New Issue
Block a user