diff --git a/client/src/api/feedback.ts b/client/src/api/feedback.ts new file mode 100644 index 0000000..73e7088 --- /dev/null +++ b/client/src/api/feedback.ts @@ -0,0 +1,20 @@ +import { request } from '@/utils/request' + +/** 提交反馈 */ +export function submitFeedback(data: { + type: string + content: string + contact?: string +}) { + return request('/feedback', { method: 'POST', data }) +} + +/** 获取反馈列表(管理员) */ +export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) { + return request('/feedback', { params }) +} + +/** 更新反馈状态(管理员) */ +export function updateFeedbackStatus(id: number, status: string) { + return request(`/feedback/${id}/status`, { method: 'PUT', data: { status } }) +} diff --git a/client/src/pages.json b/client/src/pages.json index 573f229..c03dba8 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -96,6 +96,27 @@ "navigationBarTitleText": "公告管理", "enablePullDownRefresh": true } + }, + { + "path": "pages/privacy/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "隐私政策" + } + }, + { + "path": "pages/feedback/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "意见反馈" + } + }, + { + "path": "pages/admin/feedback", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "反馈管理" + } } ], "globalStyle": { diff --git a/client/src/pages/admin/feedback.vue b/client/src/pages/admin/feedback.vue new file mode 100644 index 0000000..8a0de9e --- /dev/null +++ b/client/src/pages/admin/feedback.vue @@ -0,0 +1,418 @@ + + + + + diff --git a/client/src/pages/admin/index.vue b/client/src/pages/admin/index.vue index f652ae2..bdab582 100644 --- a/client/src/pages/admin/index.vue +++ b/client/src/pages/admin/index.vue @@ -72,6 +72,11 @@ 公告管理 + + + 反馈管理 + + @@ -114,6 +119,7 @@ onMounted(async () => { function goBack() { uni.navigateBack() } function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) } function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) } +function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) } diff --git a/client/src/pages/privacy/index.vue b/client/src/pages/privacy/index.vue new file mode 100644 index 0000000..171189a --- /dev/null +++ b/client/src/pages/privacy/index.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/client/src/pages/profile/index.vue b/client/src/pages/profile/index.vue index bc78ff4..9516e41 100644 --- a/client/src/pages/profile/index.vue +++ b/client/src/pages/profile/index.vue @@ -58,10 +58,18 @@ + + 意见反馈 + + 关于小菜 + + 隐私政策 + + @@ -193,6 +201,14 @@ function goCategoryManage() { uni.navigateTo({ url: '/pages/category-manage/index' }) } +function goPrivacy() { + uni.navigateTo({ url: '/pages/privacy/index' }) +} + +function goFeedback() { + uni.navigateTo({ url: '/pages/feedback/index' }) +} + function goProfileEdit() { uni.navigateTo({ url: '/pages/profile-edit/index' }) } diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 4c8b32c..c8265d8 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -114,3 +114,19 @@ CREATE TABLE IF NOT EXISTS saved_filters ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS feedbacks ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型', + content TEXT NOT NULL COMMENT '反馈内容', + contact VARCHAR(100) DEFAULT '' COMMENT '联系方式', + status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态', + admin_reply TEXT DEFAULT '' COMMENT '管理员回复', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_status (status), + INDEX idx_created (created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/src/index.ts b/server/src/index.ts index c052c17..ffdd491 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -18,6 +18,7 @@ import groupRoutes from './routes/group' import notificationRoutes from './routes/notification' import filterRoutes from './routes/filter' import adminRoutes from './routes/admin' +import feedbackRoutes from './routes/feedback' import { backupDatabase } from './utils/backup' // Warn if token secret is using default fallback @@ -149,6 +150,7 @@ app.use('/api/groups', apiLimiter, groupRoutes) app.use('/api/notifications', apiLimiter, notificationRoutes) app.use('/api/filters', apiLimiter, filterRoutes) app.use('/api/admin', apiLimiter, adminRoutes) +app.use('/api/feedback', apiLimiter, feedbackRoutes) app.get('/api/health', async (_req, res) => { try { diff --git a/server/src/routes/feedback.ts b/server/src/routes/feedback.ts new file mode 100644 index 0000000..9677b94 --- /dev/null +++ b/server/src/routes/feedback.ts @@ -0,0 +1,110 @@ +import { Router } from 'express' +import { pool } from '../db/connection' +import { authMiddleware } from '../middleware/auth' + +const router = Router() + +/** 用户提交反馈 */ +router.post('/', authMiddleware, async (req, res) => { + try { + const userId = (req as any).userId + const { type, content, contact } = req.body + + if (!content || !content.trim()) { + return res.status(400).json({ error: '请填写反馈内容' }) + } + + const validTypes = ['bug', 'feature', 'ux', 'other'] + const feedbackType = validTypes.includes(type) ? type : 'other' + + await pool.execute( + 'INSERT INTO feedbacks (user_id, type, content, contact) VALUES (?, ?, ?, ?)', + [userId, feedbackType, content.trim(), contact || ''] + ) + + res.json({ success: true }) + } catch (error) { + console.error('Submit feedback error:', error) + res.status(500).json({ error: '提交失败' }) + } +}) + +/** 管理员获取反馈列表 */ +router.get('/', authMiddleware, async (req, res) => { + try { + const userId = (req as any).userId + const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId]) + const user = (users as any[])[0] + + if (!user || user.role !== 'admin') { + return res.status(403).json({ error: '无权限' }) + } + + const page = Math.max(1, parseInt(req.query.page as string) || 1) + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20)) + const status = req.query.status as string + const offset = (page - 1) * pageSize + + let where = '1=1' + const params: any[] = [] + + if (status && ['pending', 'processed', 'ignored'].includes(status)) { + where += ' AND f.status = ?' + params.push(status) + } + + const [countResult] = await pool.execute( + `SELECT COUNT(*) as total FROM feedbacks f WHERE ${where}`, + params + ) + const total = (countResult as any[])[0].total + + const [rows] = await pool.execute( + `SELECT f.*, u.nickname, u.avatar_url + FROM feedbacks f + LEFT JOIN users u ON f.user_id = u.id + WHERE ${where} + ORDER BY f.created_at DESC + LIMIT ? OFFSET ?`, + [...params, pageSize, offset] + ) + + res.json({ list: rows, total, page, pageSize }) + } catch (error) { + console.error('Get feedback list error:', error) + res.status(500).json({ error: '获取失败' }) + } +}) + +/** 管理员更新反馈状态 */ +router.put('/:id/status', authMiddleware, async (req, res) => { + try { + const userId = (req as any).userId + const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId]) + const user = (users as any[])[0] + + if (!user || user.role !== 'admin') { + return res.status(403).json({ error: '无权限' }) + } + + const { id } = req.params + const { status, admin_reply } = req.body + + const validStatuses = ['pending', 'processed', 'ignored'] + if (!validStatuses.includes(status)) { + return res.status(400).json({ error: '无效的状态' }) + } + + await pool.execute( + 'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?', + [status, admin_reply || '', id] + ) + + res.json({ success: true }) + } catch (error) { + console.error('Update feedback status error:', error) + res.status(500).json({ error: '更新失败' }) + } +}) + +export default router