diff --git a/api/controllers/adminController.ts b/api/controllers/adminController.ts index 87ab3cd..63e59f6 100644 --- a/api/controllers/adminController.ts +++ b/api/controllers/adminController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { SystemConfigModel } from '../models'; +import { query } from '../database'; export class AdminController { // 管理员登录 @@ -126,6 +127,149 @@ export class AdminController { } } + static async getUserStats(req: Request, res: Response) { + try { + const rows = await query( + ` + SELECT + u.id as userId, + u.name as userName, + COUNT(qr.id) as totalRecords, + COALESCE(ROUND(AVG(qr.total_score), 2), 0) as averageScore, + COALESCE(MAX(qr.total_score), 0) as highestScore, + COALESCE(MIN(qr.total_score), 0) as lowestScore + FROM users u + LEFT JOIN quiz_records qr ON u.id = qr.user_id + GROUP BY u.id + ORDER BY totalRecords DESC, u.created_at DESC + `, + ); + + const data = (rows as any[]).map((row) => ({ + userId: row.userId, + userName: row.userName, + totalRecords: Number(row.totalRecords) || 0, + averageScore: Number(row.averageScore) || 0, + highestScore: Number(row.highestScore) || 0, + lowestScore: Number(row.lowestScore) || 0, + })); + + res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error('获取用户统计失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取用户统计失败', + }); + } + } + + static async getSubjectStats(req: Request, res: Response) { + try { + const rows = await query( + ` + SELECT + s.id as subjectId, + s.name as subjectName, + COUNT(qr.id) as totalRecords, + COALESCE(ROUND(AVG(qr.total_score), 2), 0) as averageScore, + COALESCE(ROUND(AVG( + CASE + WHEN qr.total_count > 0 THEN (qr.correct_count * 100.0 / qr.total_count) + ELSE 0 + END + ), 2), 0) as averageCorrectRate + FROM exam_subjects s + LEFT JOIN quiz_records qr ON s.id = qr.subject_id + GROUP BY s.id + ORDER BY totalRecords DESC, s.created_at DESC + `, + ); + + const data = (rows as any[]).map((row) => ({ + subjectId: row.subjectId, + subjectName: row.subjectName, + totalRecords: Number(row.totalRecords) || 0, + averageScore: Number(row.averageScore) || 0, + averageCorrectRate: Number(row.averageCorrectRate) || 0, + })); + + res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error('获取科目统计失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取科目统计失败', + }); + } + } + + static async getTaskStats(req: Request, res: Response) { + try { + const rows = await query( + ` + SELECT + t.id as taskId, + t.name as taskName, + COALESCE(rs.totalRecords, 0) as totalRecords, + COALESCE(rs.averageScore, 0) as averageScore, + COALESCE(us.totalUsers, 0) as totalUsers, + COALESCE(rs.completedUsers, 0) as completedUsers + FROM exam_tasks t + LEFT JOIN ( + SELECT + task_id, + COUNT(*) as totalRecords, + ROUND(AVG(total_score), 2) as averageScore, + COUNT(DISTINCT user_id) as completedUsers + FROM quiz_records + WHERE task_id IS NOT NULL + GROUP BY task_id + ) rs ON rs.task_id = t.id + LEFT JOIN ( + SELECT + task_id, + COUNT(DISTINCT user_id) as totalUsers + FROM exam_task_users + GROUP BY task_id + ) us ON us.task_id = t.id + ORDER BY t.created_at DESC + `, + ); + + const data = rows.map((row: any) => { + const totalUsers = Number(row.totalUsers) || 0; + const completedUsers = Number(row.completedUsers) || 0; + const completionRate = totalUsers > 0 ? (completedUsers / totalUsers) * 100 : 0; + + return { + taskId: row.taskId, + taskName: row.taskName, + totalRecords: Number(row.totalRecords) || 0, + averageScore: Number(row.averageScore) || 0, + completionRate, + }; + }); + + res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error('获取任务统计失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取任务统计失败', + }); + } + } + // 修改管理员密码 static async updatePassword(req: Request, res: Response) { try { @@ -180,4 +324,4 @@ export class AdminController { }); } } -} \ No newline at end of file +} diff --git a/api/server.ts b/api/server.ts index 00e0397..343db06 100644 --- a/api/server.ts +++ b/api/server.ts @@ -103,6 +103,9 @@ apiRouter.get('/quiz/records', adminAuth, QuizController.getAllRecords); // 管理员相关 apiRouter.post('/admin/login', AdminController.login); apiRouter.get('/admin/statistics', adminAuth, AdminController.getStatistics); +apiRouter.get('/admin/statistics/users', adminAuth, AdminController.getUserStats); +apiRouter.get('/admin/statistics/subjects', adminAuth, AdminController.getSubjectStats); +apiRouter.get('/admin/statistics/tasks', adminAuth, AdminController.getTaskStats); apiRouter.get('/admin/active-tasks', adminAuth, AdminController.getActiveTasksStats); apiRouter.put('/admin/config', adminAuth, AdminController.updateQuizConfig); apiRouter.get('/admin/config', adminAuth, AdminController.getQuizConfig); @@ -154,4 +157,4 @@ async function startServer() { // 启动服务器 // 重启触发 -startServer(); \ No newline at end of file +startServer(); diff --git a/openspec/changes/update-admin-login-dashboard/proposal.md b/openspec/changes/update-admin-login-dashboard/proposal.md new file mode 100644 index 0000000..aa4e687 --- /dev/null +++ b/openspec/changes/update-admin-login-dashboard/proposal.md @@ -0,0 +1,28 @@ +# 管理员登录记录与仪表盘数据拉取优化 + +## Context +- 管理端登录页存在“最近登录记录”体验需求,且需要确保不会保存失败登录或敏感信息(如密码)。 +- 管理端仪表盘/统计页存在通过 `fetch` 手写请求与鉴权头、与 `src/services/api.ts` 统一封装并存的情况,导致代码重复且行为不一致。 + +## Goals / Non-Goals +- Goals: + - 管理员登录页仅在登录成功时写入最近登录记录(用户名 + 时间戳),且不保存密码。 + - 管理端页面统一使用现有的 `api`/`adminAPI`/`quizAPI` 访问后端,避免重复拼接 token 与重复处理响应格式。 +- Non-Goals: + - 不调整管理员鉴权机制(`adminAuth` 仍为简化实现)。 + - 不新增第三方图表库或引入新的前端数据层框架。 + +## Decisions +- 决策:复用 `src/services/api.ts` 的 axios 实例与拦截器,统一处理 token 注入与 `success`/`message` 响应格式。 + - 理由:仓库已存在稳定封装,减少重复与差异。 +- 决策:最近答题记录使用现有 `/api/quiz/records` 接口拉取(分页参数 `page=1&limit=10`)。 + - 理由:后端已实现管理员分页接口;前端无需再手写 `fetch`。 + +## Risks / Trade-offs +- 统一到 axios 封装后,错误提示将由拦截器抛错路径主导。 + - 缓解:在页面层使用 `message.error(error.message)`,保持用户感知一致。 + +## Migration Plan +- 前端无数据迁移:登录记录仍存储在 localStorage,仅调整写入触发条件与读取方式。 +- 回滚策略:恢复为原先 `fetch` 调用与原登录记录写入逻辑。 + diff --git a/openspec/changes/update-admin-login-dashboard/specs/admin-portal/spec.md b/openspec/changes/update-admin-login-dashboard/specs/admin-portal/spec.md new file mode 100644 index 0000000..5bc2224 --- /dev/null +++ b/openspec/changes/update-admin-login-dashboard/specs/admin-portal/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Admin Login Recent Records +系统 MUST 仅在管理员登录成功时保存最近登录记录;最近登录记录 MUST 仅包含 `username` 与 `timestamp`,且 MUST NOT 保存密码等敏感信息。 + +#### Scenario: Save record only on successful login +- **GIVEN** 管理员在登录页提交用户名与密码 +- **WHEN** 后端返回 `success: true` +- **THEN** 系统 MUST 保存该用户名与当前时间戳到最近登录记录 + +#### Scenario: Do not save record on failed login +- **GIVEN** 管理员在登录页提交用户名与密码 +- **WHEN** 后端返回 `success: false` 或请求失败 +- **THEN** 系统 MUST NOT 写入最近登录记录 + +#### Scenario: Select recent record +- **GIVEN** 最近登录记录列表存在至少一条记录 +- **WHEN** 管理员选择某条最近登录记录 +- **THEN** 系统 MUST 回填该记录的用户名 +- **AND** 系统 MUST 清空密码输入框 + +### Requirement: Admin Pages Use Standard API Client +管理端页面 MUST 复用 `src/services/api.ts` 的 API 封装发起请求,以统一 token 注入与响应格式处理。 + +#### Scenario: Dashboard fetches recent records +- **GIVEN** 管理员已登录并持有 token +- **WHEN** 仪表盘加载最近答题记录 +- **THEN** 系统 MUST 通过统一 API 客户端调用 `/api/quiz/records` 获取数据 diff --git a/openspec/changes/update-admin-login-dashboard/tasks.md b/openspec/changes/update-admin-login-dashboard/tasks.md new file mode 100644 index 0000000..efb65a3 --- /dev/null +++ b/openspec/changes/update-admin-login-dashboard/tasks.md @@ -0,0 +1,13 @@ +## 1. 登录记录 +- [ ] 1.1 确保仅在登录成功时写入最近登录记录 +- [ ] 1.2 确保最近登录记录不包含密码等敏感字段 +- [ ] 1.3 确保选择历史记录仅回填用户名并清空密码 + +## 2. 仪表盘与统计页数据拉取 +- [ ] 2.1 仪表盘最近答题记录改为使用 `quizAPI.getAllRecords` +- [ ] 2.2 统计页数据拉取改为复用现有 API 封装,移除手写 token 逻辑 + +## 3. 测试与校验 +- [ ] 3.1 补充可执行的前端单测覆盖登录记录写入逻辑 +- [ ] 3.2 运行 `npm run check` 与 `npm run build` + diff --git a/src/pages/admin/AdminDashboardPage.tsx b/src/pages/admin/AdminDashboardPage.tsx index 7c6c62d..62c225d 100644 --- a/src/pages/admin/AdminDashboardPage.tsx +++ b/src/pages/admin/AdminDashboardPage.tsx @@ -1,12 +1,12 @@ import { useState, useEffect } from 'react'; -import { Card, Row, Col, Statistic, Button, Table, message, Tooltip } from 'antd'; +import { Card, Row, Col, Statistic, Button, Table, message } from 'antd'; import { UserOutlined, QuestionCircleOutlined, BarChartOutlined, ReloadOutlined } from '@ant-design/icons'; -import { adminAPI } from '../../services/api'; +import { adminAPI, quizAPI } from '../../services/api'; import { formatDateTime } from '../../utils/validation'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend, Label } from 'recharts'; @@ -73,14 +73,8 @@ const AdminDashboardPage = () => { }; const fetchRecentRecords = async () => { - // 这里简化处理,实际应该调用专门的API - const response = await fetch('/api/quiz/records?page=1&limit=10', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}` - } - }); - const data = await response.json(); - return data.success ? data.data : []; + const response = await quizAPI.getAllRecords({ page: 1, limit: 10 }) as any; + return response.data || []; }; const columns = [ diff --git a/src/pages/admin/StatisticsPage.tsx b/src/pages/admin/StatisticsPage.tsx index 0150b86..6a48a89 100644 --- a/src/pages/admin/StatisticsPage.tsx +++ b/src/pages/admin/StatisticsPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { Card, Row, Col, Statistic, Table, DatePicker, Button, message, Tabs, Select } from 'antd'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'; -import { adminAPI } from '../../services/api'; +import api, { adminAPI, quizAPI } from '../../services/api'; import { formatDateTime } from '../../utils/validation'; const { RangePicker } = DatePicker; @@ -98,15 +98,8 @@ const StatisticsPage = () => { const fetchRecords = async () => { try { - const response = await fetch('/api/quiz/records?page=1&limit=100', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}` - } - }); - const data = await response.json(); - if (data.success) { - setRecords(data.data); - } + const response = await quizAPI.getAllRecords({ page: 1, limit: 100 }) as any; + setRecords(response.data || []); } catch (error) { console.error('获取答题记录失败:', error); } @@ -114,15 +107,8 @@ const StatisticsPage = () => { const fetchUserStats = async () => { try { - const response = await fetch('/api/admin/statistics/users', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}` - } - }); - const data = await response.json(); - if (data.success) { - setUserStats(data.data); - } + const response = await adminAPI.getUserStats() as any; + setUserStats(response.data || []); } catch (error) { console.error('获取用户统计失败:', error); } @@ -130,15 +116,8 @@ const StatisticsPage = () => { const fetchSubjectStats = async () => { try { - const response = await fetch('/api/admin/statistics/subjects', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}` - } - }); - const data = await response.json(); - if (data.success) { - setSubjectStats(data.data); - } + const response = await adminAPI.getSubjectStats() as any; + setSubjectStats(response.data || []); } catch (error) { console.error('获取科目统计失败:', error); } @@ -146,15 +125,8 @@ const StatisticsPage = () => { const fetchTaskStats = async () => { try { - const response = await fetch('/api/admin/statistics/tasks', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}` - } - }); - const data = await response.json(); - if (data.success) { - setTaskStats(data.data); - } + const response = await adminAPI.getTaskStats() as any; + setTaskStats(response.data || []); } catch (error) { console.error('获取任务统计失败:', error); } @@ -162,11 +134,8 @@ const StatisticsPage = () => { const fetchSubjects = async () => { try { - const response = await fetch('/api/exam-subjects'); - const data = await response.json(); - if (data.success) { - setSubjects(data.data); - } + const response = await api.get('/exam-subjects') as any; + setSubjects(response.data || []); } catch (error) { console.error('获取科目列表失败:', error); } @@ -174,11 +143,8 @@ const StatisticsPage = () => { const fetchTasks = async () => { try { - const response = await fetch('/api/exam-tasks'); - const data = await response.json(); - if (data.success) { - setTasks(data.data); - } + const response = await api.get('/exam-tasks') as any; + setTasks(response.data || []); } catch (error) { console.error('获取任务列表失败:', error); } @@ -433,4 +399,4 @@ const StatisticsPage = () => { ); }; -export default StatisticsPage; \ No newline at end of file +export default StatisticsPage; diff --git a/src/services/api.ts b/src/services/api.ts index c96e5c3..e5be0bc 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -110,6 +110,9 @@ export const adminAPI = { updateQuizConfig: (data: any) => api.put('/admin/config', data), getStatistics: () => api.get('/admin/statistics'), getActiveTasksStats: () => api.get('/admin/active-tasks'), + getUserStats: () => api.get('/admin/statistics/users'), + getSubjectStats: () => api.get('/admin/statistics/subjects'), + getTaskStats: () => api.get('/admin/statistics/tasks'), updatePassword: (data: { username: string; oldPassword: string; newPassword: string }) => api.put('/admin/password', data), };