diff --git a/backend/db.js b/backend/db.js index 7c0498c..d50b077 100644 --- a/backend/db.js +++ b/backend/db.js @@ -224,6 +224,15 @@ async function initSchema(connection) { INDEX idx_item (item_id) ) 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 ( id INT AUTO_INCREMENT PRIMARY KEY, group_name VARCHAR(100) NOT NULL, diff --git a/backend/routes/features.js b/backend/routes/features.js index 601caa3..140ab1a 100644 --- a/backend/routes/features.js +++ b/backend/routes/features.js @@ -5,19 +5,31 @@ const { authenticate, requireRole } = require('../middleware/auth'); const router = express.Router(); 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'; 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 + (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 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); }); +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) => { const { title, description, group_name, server_name } = req.body; 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) => { + 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_items WHERE id = ?', [req.params.id]); res.json({ message: '已删除' }); @@ -56,4 +69,13 @@ router.post('/:id/vote', authenticate, async (req, res) => { 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; diff --git a/public/js/pages/features.js b/public/js/pages/features.js index 8760652..0c9280c 100644 --- a/public/js/pages/features.js +++ b/public/js/pages/features.js @@ -2,6 +2,7 @@ const FeaturesPage = { sort: 'time', filterGroup: '', filterServer: '', + filterStatus: '', async render() { return '
'; }, @@ -16,12 +17,13 @@ const FeaturesPage = { renderLayout() { const ct = document.getElementById('page-content'); - ct.innerHTML = `
+ ct.innerHTML = `
+ - - + +
`; this.loadGroupOptions(); @@ -43,6 +45,7 @@ const FeaturesPage = { setFilter() { this.filterGroup = document.getElementById('feat-group').value; this.filterServer = document.getElementById('feat-server').value; + this.filterStatus = document.getElementById('feat-status').value; this.loadGroupOptions(); this.load(); }, @@ -58,41 +61,86 @@ const FeaturesPage = { 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 params = new URLSearchParams({ sort: this.sort, group_name: this.filterGroup, server_name: this.filterServer, status: this.filterStatus }); const items = await API.get('/features?' + params); - ct.innerHTML = items.length === 0 ? '

暂无

' + ct.innerHTML = items.length === 0 ? '

暂无内容

' : 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 = `
${e.message}
`; } }, renderItem(i) { 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 `
- ${U.esc(i.title)} ${i.group_name ? `— ${U.esc(i.group_name)}/${U.esc(i.server_name)}` : ''} + ${i.status==='done'?'\u2713':i.status==='planned'?'\u25C9':'\u25CB'} ${U.esc(i.title)}
- ● ${sl} + ${i.group_name ? `${U.esc(i.group_name)}` : ''} ${Auth.isOwner() ? `` : ''}
- ${i.description ? `

${U.esc(i.description)}

` : ''} -
- - ${i.my_vote ? '已投票' : '投票'} + ${i.description ? `

${U.esc(i.description)}

` : ''} +
+ + ${i.comments_count||0} 讨论 + ${U.date(i.created_at)} +
+ +
`; }, + 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 ? '

暂无评论

' + : item.comments.map(c => `
+ ${U.esc(c.username||'用户')} + ${c.role ? U.badge(c.role, 'role') : ''} + ${U.date(c.created_at)} +
${U.esc(c.content)}
+
`).join(''); + } catch { ct.innerHTML = '

加载失败

'; } + }, + + 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) { const card = document.getElementById('feat-'+i.id); 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); } }); }, @@ -102,11 +150,11 @@ const FeaturesPage = { async showCreate() { const groups = await this.loadGroupsForForm(); const opts = groups.map(g => ``).join(''); - U.modal('添加项目', ` + U.modal('添加', `
-
+
`); @@ -125,7 +173,7 @@ const FeaturesPage = {
-
+
`);