feat: features as GitHub Issues style with comments/discussion, status filter
This commit is contained in:
@@ -224,6 +224,15 @@ async function initSchema(connection) {
|
|||||||
INDEX idx_item (item_id)
|
INDEX idx_item (item_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('feature_comments', `CREATE TABLE feature_comments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_item (item_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
await createIfNotExists('server_groups', `CREATE TABLE server_groups (
|
await createIfNotExists('server_groups', `CREATE TABLE server_groups (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
group_name VARCHAR(100) NOT NULL,
|
group_name VARCHAR(100) NOT NULL,
|
||||||
|
|||||||
@@ -5,19 +5,31 @@ const { authenticate, requireRole } = require('../middleware/auth');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', authenticate, async (req, res) => {
|
router.get('/', authenticate, async (req, res) => {
|
||||||
const { group_name, server_name, sort } = req.query;
|
const { group_name, server_name, sort, status } = req.query;
|
||||||
let orderClause = 'ORDER BY fi.created_at DESC';
|
let orderClause = 'ORDER BY fi.created_at DESC';
|
||||||
if (sort === 'votes') orderClause = 'ORDER BY votes_count DESC, fi.created_at DESC';
|
if (sort === 'votes') orderClause = 'ORDER BY votes_count DESC, fi.created_at DESC';
|
||||||
|
|
||||||
const items = await query(`SELECT fi.*,
|
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) as votes_count,
|
||||||
(SELECT COUNT(*) FROM feature_votes WHERE item_id = fi.id AND user_id = ?) as my_vote
|
(SELECT COUNT(*) FROM feature_votes WHERE item_id = fi.id AND user_id = ?) as my_vote,
|
||||||
|
(SELECT COUNT(*) FROM feature_comments WHERE item_id = fi.id) as comments_count
|
||||||
FROM feature_items fi
|
FROM feature_items fi
|
||||||
WHERE (fi.group_name = ? OR ? = '') AND (fi.server_name = ? OR ? = '')
|
WHERE (fi.group_name = ? OR ? = '') AND (fi.server_name = ? OR ? = '')
|
||||||
${orderClause}`, [req.user.id, group_name||'', group_name||'', server_name||'', server_name||'']);
|
AND (fi.status = ? OR ? = '' OR ? = 'all')
|
||||||
|
${orderClause}`, [req.user.id, group_name||'', group_name||'', server_name||'', server_name||'', status||'', status||'', status||'']);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id', authenticate, async (req, res) => {
|
||||||
|
const item = await getRow(`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.id = ?`, [req.user.id, req.params.id]);
|
||||||
|
if (!item) return res.status(404).json({ error: '不存在' });
|
||||||
|
item.comments = await query(`SELECT fc.*, u.username, u.role FROM feature_comments fc LEFT JOIN users u ON fc.user_id = u.id WHERE fc.item_id = ? ORDER BY fc.created_at ASC`, [req.params.id]);
|
||||||
|
res.json(item);
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, requireRole('owner'), async (req, res) => {
|
router.post('/', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
const { title, description, group_name, server_name } = req.body;
|
const { title, description, group_name, server_name } = req.body;
|
||||||
if (!title) return res.status(400).json({ error: '标题为必填' });
|
if (!title) return res.status(400).json({ error: '标题为必填' });
|
||||||
@@ -39,6 +51,7 @@ router.put('/:id', authenticate, requireRole('owner'), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, requireRole('owner'), async (req, res) => {
|
router.delete('/:id', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
|
await query('DELETE FROM feature_comments WHERE item_id = ?', [req.params.id]);
|
||||||
await query('DELETE FROM feature_votes WHERE item_id = ?', [req.params.id]);
|
await query('DELETE FROM feature_votes WHERE item_id = ?', [req.params.id]);
|
||||||
await query('DELETE FROM feature_items WHERE id = ?', [req.params.id]);
|
await query('DELETE FROM feature_items WHERE id = ?', [req.params.id]);
|
||||||
res.json({ message: '已删除' });
|
res.json({ message: '已删除' });
|
||||||
@@ -56,4 +69,13 @@ router.post('/:id/vote', authenticate, async (req, res) => {
|
|||||||
res.json({ message: '投票成功' });
|
res.json({ message: '投票成功' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/:id/comment', authenticate, async (req, res) => {
|
||||||
|
const { content } = req.body;
|
||||||
|
if (!content) return res.status(400).json({ error: '内容不能为空' });
|
||||||
|
const item = await getRow('SELECT id FROM feature_items WHERE id = ?', [req.params.id]);
|
||||||
|
if (!item) return res.status(404).json({ error: '不存在' });
|
||||||
|
await query('INSERT INTO feature_comments(item_id, user_id, content) VALUES (?,?,?)', [req.params.id, req.user.id, content]);
|
||||||
|
res.status(201).json({ message: '评论成功' });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const FeaturesPage = {
|
|||||||
sort: 'time',
|
sort: 'time',
|
||||||
filterGroup: '',
|
filterGroup: '',
|
||||||
filterServer: '',
|
filterServer: '',
|
||||||
|
filterStatus: '',
|
||||||
|
|
||||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
@@ -16,12 +17,13 @@ const FeaturesPage = {
|
|||||||
|
|
||||||
renderLayout() {
|
renderLayout() {
|
||||||
const ct = document.getElementById('page-content');
|
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">
|
ct.innerHTML = `<div 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-group" onchange="FeaturesPage.setFilter()"><option value="">全部分组</option></select>
|
||||||
<select id="feat-server" onchange="FeaturesPage.setFilter()"><option value="">全部子服</option></select>
|
<select id="feat-server" onchange="FeaturesPage.setFilter()"><option value="">全部子服</option></select>
|
||||||
|
<select id="feat-status" onchange="FeaturesPage.setFilter()"><option value="all">全部状态</option><option value="pending">待定</option><option value="planned">计划中</option><option value="done">已完成</option></select>
|
||||||
<span style="flex:1"></span>
|
<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==='time'?'btn-p':''}" 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>
|
<button class="btn btn-o btn-sm ${this.sort==='votes'?'btn-p':''}" id="sort-votes" onclick="FeaturesPage.setSort('votes')">热度</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="feat-list"></div>`;
|
<div id="feat-list"></div>`;
|
||||||
this.loadGroupOptions();
|
this.loadGroupOptions();
|
||||||
@@ -43,6 +45,7 @@ const FeaturesPage = {
|
|||||||
setFilter() {
|
setFilter() {
|
||||||
this.filterGroup = document.getElementById('feat-group').value;
|
this.filterGroup = document.getElementById('feat-group').value;
|
||||||
this.filterServer = document.getElementById('feat-server').value;
|
this.filterServer = document.getElementById('feat-server').value;
|
||||||
|
this.filterStatus = document.getElementById('feat-status').value;
|
||||||
this.loadGroupOptions();
|
this.loadGroupOptions();
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
@@ -58,41 +61,86 @@ const FeaturesPage = {
|
|||||||
const ct = document.getElementById('feat-list');
|
const ct = document.getElementById('feat-list');
|
||||||
U.loading(ct);
|
U.loading(ct);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ sort: this.sort, group_name: this.filterGroup, server_name: this.filterServer });
|
const params = new URLSearchParams({ sort: this.sort, group_name: this.filterGroup, server_name: this.filterServer, status: this.filterStatus });
|
||||||
const items = await API.get('/features?' + params);
|
const items = await API.get('/features?' + params);
|
||||||
ct.innerHTML = items.length === 0 ? '<div class="empty"><i class="fas fa-lightbulb"></i><p>暂无</p></div>'
|
ct.innerHTML = items.length === 0 ? '<div class="empty"><i class="fas fa-inbox"></i><p>暂无内容</p></div>'
|
||||||
: items.map(i => this.renderItem(i)).join('');
|
: items.map(i => this.renderItem(i)).join('');
|
||||||
for (const i of items) if (Auth.logged()) this.bindVote(i);
|
for (const i of items) {
|
||||||
|
if (Auth.logged()) this.bindVote(i);
|
||||||
|
this.bindComment(i);
|
||||||
|
}
|
||||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
},
|
},
|
||||||
|
|
||||||
renderItem(i) {
|
renderItem(i) {
|
||||||
const sc = i.status==='done'?'var(--s)':i.status==='planned'?'var(--p)':'var(--g5)';
|
const sc = i.status==='done'?'var(--s)':i.status==='planned'?'var(--p)':'var(--g5)';
|
||||||
const sl = i.status==='done'?'已完成':i.status==='planned'?'计划中':'待定';
|
const statusLabel = i.status==='done'?'已完成':i.status==='planned'?'计划中':'待定';
|
||||||
return `<div class="card" id="feat-${i.id}">
|
return `<div class="card" id="feat-${i.id}">
|
||||||
<div class="card-h">
|
<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>
|
<span><span style="color:${sc};font-size:18px;margin-right:8px">${i.status==='done'?'\u2713':i.status==='planned'?'\u25C9':'\u25CB'}</span> ${U.esc(i.title)}</span>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<span style="font-size:11px;color:${sc}">● ${sl}</span>
|
${i.group_name ? `<span class="badge bg-player">${U.esc(i.group_name)}</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>` : ''}
|
${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>
|
</div>
|
||||||
<div class="card-b">
|
<div class="card-b">
|
||||||
${i.description ? `<p class="tm mb-4">${U.esc(i.description)}</p>` : ''}
|
${i.description ? `<p style="white-space:pre-wrap;margin-bottom:12px">${U.esc(i.description)}</p>` : ''}
|
||||||
<div style="display:flex;align-items:center;gap:12px">
|
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px">
|
||||||
<button class="btn btn-${i.my_vote?'s':'o'} btn-sm feat-vote" data-id="${i.id}">
|
<button class="btn btn-${i.my_vote?'s':'o'} btn-sm feat-vote" data-id="${i.id}"><i class="fas fa-thumbs-up"></i> ${i.votes_count||0}</button>
|
||||||
<i class="fas fa-chevron-up"></i> ${i.votes_count || 0}
|
<span class="ts tm"><i class="fas fa-comment"></i> ${i.comments_count||0} 讨论</span>
|
||||||
</button>
|
<span class="ts tm">${U.date(i.created_at)}</span>
|
||||||
<span class="ts tm">${i.my_vote ? '已投票' : '投票'}</span>
|
</div>
|
||||||
|
<button class="btn btn-o btn-sm w-full" onclick="FeaturesPage.toggleDiscuss(${i.id})"><i class="fas fa-comments"></i> 展开讨论</button>
|
||||||
|
<div id="discuss-${i.id}" style="display:none;margin-top:12px;border-top:1px solid var(--g200);padding-top:12px">
|
||||||
|
<div id="comments-${i.id}" class="mb-4"></div>
|
||||||
|
<form id="comment-form-${i.id}" class="flex g2"><input id="comment-input-${i.id}" placeholder="添加评论..." style="flex:1;padding:8px 12px;border:1px solid var(--g200);border-radius:var(--r)"><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i></button></form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleDiscuss(id) {
|
||||||
|
const el = document.getElementById('discuss-'+id);
|
||||||
|
if (el.style.display === 'none') {
|
||||||
|
el.style.display = 'block';
|
||||||
|
this.loadComments(id);
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadComments(id) {
|
||||||
|
const ct = document.getElementById('comments-'+id);
|
||||||
|
try {
|
||||||
|
const item = await API.get('/features/'+id);
|
||||||
|
ct.innerHTML = item.comments.length === 0 ? '<p class="tm ts">暂无评论</p>'
|
||||||
|
: item.comments.map(c => `<div style="padding:6px 0;border-bottom:1px solid var(--g100)">
|
||||||
|
<span class="fb">${U.esc(c.username||'用户')}</span>
|
||||||
|
${c.role ? U.badge(c.role, 'role') : ''}
|
||||||
|
<span class="ts tm">${U.date(c.created_at)}</span>
|
||||||
|
<div style="margin-top:4px;white-space:pre-wrap">${U.esc(c.content)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} catch { ct.innerHTML = '<p class="tm">加载失败</p>'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
bindComment(i) {
|
||||||
|
const form = document.getElementById('comment-form-'+i.id);
|
||||||
|
if (!form) return;
|
||||||
|
form.onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('comment-input-'+i.id);
|
||||||
|
const content = input.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
try { await API.post(`/features/${i.id}/comment`, { content }); input.value = ''; this.loadComments(i.id); this.load(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
bindVote(i) {
|
bindVote(i) {
|
||||||
const card = document.getElementById('feat-'+i.id);
|
const card = document.getElementById('feat-'+i.id);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
card.querySelector('.feat-vote').addEventListener('click', async () => {
|
const btn = card.querySelector('.feat-vote');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
try { await API.post(`/features/${i.id}/vote`); this.load(); } catch (ex) { alert(ex.message); }
|
try { await API.post(`/features/${i.id}/vote`); this.load(); } catch (ex) { alert(ex.message); }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -102,11 +150,11 @@ const FeaturesPage = {
|
|||||||
async showCreate() {
|
async showCreate() {
|
||||||
const groups = await this.loadGroupsForForm();
|
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('');
|
const opts = groups.map(g => `<option value="${g.group_name}::${g.server_name}">${g.group_name} / ${g.server_name}</option>`).join('');
|
||||||
U.modal('添加项目', `
|
U.modal('添加', `
|
||||||
<form id="feat-form">
|
<form id="feat-form">
|
||||||
<div class="grid-2"><div class="form-group"><label>标题 *</label><input id="ft-title" required></div>
|
<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><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="form-group"><label>描述</label><textarea id="ft-desc" rows="3"></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>
|
<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>
|
</form>
|
||||||
`);
|
`);
|
||||||
@@ -125,7 +173,7 @@ const FeaturesPage = {
|
|||||||
<div class="form-group"><label>标题</label><input id="fe-title" value="${title}"></div>
|
<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="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><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="form-group"><label>描述</label><textarea id="fe-desc" rows="3">${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>
|
<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>
|
</form>
|
||||||
`);
|
`);
|
||||||
|
|||||||
Reference in New Issue
Block a user