后台仪表盘重新设计---待测试
This commit is contained in:
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
startServer();
|
||||
|
||||
28
openspec/changes/update-admin-login-dashboard/proposal.md
Normal file
28
openspec/changes/update-admin-login-dashboard/proposal.md
Normal file
@@ -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` 调用与原登录记录写入逻辑。
|
||||
|
||||
@@ -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` 获取数据
|
||||
13
openspec/changes/update-admin-login-dashboard/tasks.md
Normal file
13
openspec/changes/update-admin-login-dashboard/tasks.md
Normal file
@@ -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`
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
export default StatisticsPage;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user