第一版提交,答题功能OK,题库管理待完善
This commit is contained in:
75
src/App.tsx
Normal file
75
src/App.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAdmin } from './contexts';
|
||||
|
||||
// 用户端页面
|
||||
import HomePage from './pages/HomePage';
|
||||
import QuizPage from './pages/QuizPage';
|
||||
import ResultPage from './pages/ResultPage';
|
||||
import { SubjectSelectionPage } from './pages/SubjectSelectionPage';
|
||||
import { UserTaskPage } from './pages/UserTaskPage';
|
||||
|
||||
// 管理端页面
|
||||
import AdminLoginPage from './pages/admin/AdminLoginPage';
|
||||
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
|
||||
import QuestionManagePage from './pages/admin/QuestionManagePage';
|
||||
import QuizConfigPage from './pages/admin/QuizConfigPage';
|
||||
import StatisticsPage from './pages/admin/StatisticsPage';
|
||||
import BackupRestorePage from './pages/admin/BackupRestorePage';
|
||||
import QuestionCategoryPage from './pages/admin/QuestionCategoryPage';
|
||||
import ExamSubjectPage from './pages/admin/ExamSubjectPage';
|
||||
import ExamTaskPage from './pages/admin/ExamTaskPage';
|
||||
import UserManagePage from './pages/admin/UserManagePage';
|
||||
|
||||
// 布局组件
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
|
||||
// 管理员路由守卫
|
||||
const AdminRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = useAdmin();
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/admin/login" />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Routes>
|
||||
{/* 用户端路由 */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/subjects" element={<SubjectSelectionPage />} />
|
||||
<Route path="/tasks" element={<UserTaskPage />} />
|
||||
<Route path="/quiz" element={<QuizPage />} />
|
||||
<Route path="/result/:id" element={<ResultPage />} />
|
||||
|
||||
{/* 管理端路由 */}
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminLayout>
|
||||
<Routes>
|
||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
||||
<Route path="questions" element={<QuestionManagePage />} />
|
||||
<Route path="categories" element={<QuestionCategoryPage />} />
|
||||
<Route path="subjects" element={<ExamSubjectPage />} />
|
||||
<Route path="tasks" element={<ExamTaskPage />} />
|
||||
<Route path="users" element={<UserManagePage />} />
|
||||
<Route path="config" element={<QuizConfigPage />} />
|
||||
<Route path="statistics" element={<StatisticsPage />} />
|
||||
<Route path="backup" element={<BackupRestorePage />} />
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 默认重定向 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
57
src/contexts/AdminContext.tsx
Normal file
57
src/contexts/AdminContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface Admin {
|
||||
username: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface AdminContextType {
|
||||
admin: Admin | null;
|
||||
setAdmin: (admin: Admin | null) => void;
|
||||
clearAdmin: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextType | undefined>(undefined);
|
||||
|
||||
export const AdminProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [admin, setAdmin] = useState<Admin | null>(() => {
|
||||
// 从localStorage恢复管理员信息
|
||||
const savedAdmin = localStorage.getItem('survey_admin');
|
||||
return savedAdmin ? JSON.parse(savedAdmin) : null;
|
||||
});
|
||||
|
||||
const handleSetAdmin = (newAdmin: Admin | null) => {
|
||||
setAdmin(newAdmin);
|
||||
if (newAdmin) {
|
||||
localStorage.setItem('survey_admin', JSON.stringify(newAdmin));
|
||||
} else {
|
||||
localStorage.removeItem('survey_admin');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAdmin = () => {
|
||||
handleSetAdmin(null);
|
||||
};
|
||||
|
||||
const isAuthenticated = !!admin;
|
||||
|
||||
return (
|
||||
<AdminContext.Provider value={{
|
||||
admin,
|
||||
setAdmin: handleSetAdmin,
|
||||
clearAdmin,
|
||||
isAuthenticated
|
||||
}}>
|
||||
{children}
|
||||
</AdminContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdmin = () => {
|
||||
const context = useContext(AdminContext);
|
||||
if (!context) {
|
||||
throw new Error('useAdmin必须在AdminProvider内使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
65
src/contexts/QuizContext.tsx
Normal file
65
src/contexts/QuizContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
content: string;
|
||||
type: 'single' | 'multiple' | 'judgment' | 'text';
|
||||
options?: string[];
|
||||
answer: string | string[];
|
||||
score: number;
|
||||
createdAt: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface QuizContextType {
|
||||
questions: Question[];
|
||||
setQuestions: (questions: Question[]) => void;
|
||||
currentQuestionIndex: number;
|
||||
setCurrentQuestionIndex: (index: number) => void;
|
||||
answers: Record<string, string | string[]>;
|
||||
setAnswer: (questionId: string, answer: string | string[]) => void;
|
||||
clearQuiz: () => void;
|
||||
}
|
||||
|
||||
const QuizContext = createContext<QuizContextType | undefined>(undefined);
|
||||
|
||||
export const QuizProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
|
||||
|
||||
const setAnswer = (questionId: string, answer: string | string[]) => {
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[questionId]: answer
|
||||
}));
|
||||
};
|
||||
|
||||
const clearQuiz = () => {
|
||||
setQuestions([]);
|
||||
setCurrentQuestionIndex(0);
|
||||
setAnswers({});
|
||||
};
|
||||
|
||||
return (
|
||||
<QuizContext.Provider value={{
|
||||
questions,
|
||||
setQuestions,
|
||||
currentQuestionIndex,
|
||||
setCurrentQuestionIndex,
|
||||
answers,
|
||||
setAnswer,
|
||||
clearQuiz
|
||||
}}>
|
||||
{children}
|
||||
</QuizContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useQuiz = () => {
|
||||
const context = useContext(QuizContext);
|
||||
if (!context) {
|
||||
throw new Error('useQuiz必须在QuizProvider内使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
51
src/contexts/UserContext.tsx
Normal file
51
src/contexts/UserContext.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserContextType {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
clearUser: () => void;
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
export const UserProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
// 从localStorage恢复用户信息
|
||||
const savedUser = localStorage.getItem('survey_user');
|
||||
return savedUser ? JSON.parse(savedUser) : null;
|
||||
});
|
||||
|
||||
const handleSetUser = (newUser: User | null) => {
|
||||
setUser(newUser);
|
||||
if (newUser) {
|
||||
localStorage.setItem('survey_user', JSON.stringify(newUser));
|
||||
} else {
|
||||
localStorage.removeItem('survey_user');
|
||||
}
|
||||
};
|
||||
|
||||
const clearUser = () => {
|
||||
handleSetUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, setUser: handleSetUser, clearUser }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUser = () => {
|
||||
const context = useContext(UserContext);
|
||||
if (!context) {
|
||||
throw new Error('useUser必须在UserProvider内使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
3
src/contexts/index.ts
Normal file
3
src/contexts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { UserProvider, useUser } from './UserContext';
|
||||
export { AdminProvider, useAdmin } from './AdminContext';
|
||||
export { QuizProvider, useQuiz } from './QuizContext';
|
||||
29
src/hooks/useTheme.ts
Normal file
29
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
};
|
||||
}
|
||||
80
src/index.css
Normal file
80
src/index.css
Normal file
@@ -0,0 +1,80 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 自定义样式 */
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ant-input, .ant-input-number, .ant-select-selector {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-card {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
138
src/layouts/AdminLayout.tsx
Normal file
138
src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
DatabaseOutlined,
|
||||
SafetyOutlined,
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useAdmin } from '../contexts';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const AdminLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { admin, clearAdmin } = useAdmin();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/admin/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/admin/questions',
|
||||
icon: <QuestionCircleOutlined />,
|
||||
label: '题库管理',
|
||||
},
|
||||
{
|
||||
key: '/admin/categories',
|
||||
icon: <SafetyOutlined />,
|
||||
label: '题目类别',
|
||||
},
|
||||
{
|
||||
key: '/admin/subjects',
|
||||
icon: <BookOutlined />,
|
||||
label: '考试科目',
|
||||
},
|
||||
{
|
||||
key: '/admin/tasks',
|
||||
icon: <CalendarOutlined />,
|
||||
label: '考试任务',
|
||||
},
|
||||
{
|
||||
key: '/admin/users',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/admin/config',
|
||||
icon: <SettingOutlined />,
|
||||
label: '抽题配置',
|
||||
},
|
||||
{
|
||||
key: '/admin/statistics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '数据统计',
|
||||
},
|
||||
{
|
||||
key: '/admin/backup',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '数据备份',
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAdmin();
|
||||
message.success('退出登录成功');
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} theme="light">
|
||||
<div className="logo p-4 text-center">
|
||||
<h2 className="text-lg font-bold text-blue-600 m-0">
|
||||
{collapsed ? '问卷' : '问卷系统'}
|
||||
</h2>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Header className="bg-white shadow-sm flex justify-between items-center px-6">
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <DashboardOutlined /> : <DashboardOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-lg"
|
||||
/>
|
||||
|
||||
<div className="flex items-center">
|
||||
<span className="mr-4 text-gray-600">
|
||||
欢迎,{admin?.username}
|
||||
</span>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Avatar icon={<UserOutlined />} className="cursor-pointer" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{children}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
31
src/main.tsx
Normal file
31
src/main.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { UserProvider, AdminProvider, QuizProvider } from './contexts';
|
||||
import App from './App';
|
||||
import 'antd/dist/reset.css';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserProvider>
|
||||
<AdminProvider>
|
||||
<QuizProvider>
|
||||
<App />
|
||||
</QuizProvider>
|
||||
</AdminProvider>
|
||||
</UserProvider>
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
3
src/pages/Home.tsx
Normal file
3
src/pages/Home.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Home() {
|
||||
return <div></div>;
|
||||
}
|
||||
177
src/pages/HomePage.tsx
Normal file
177
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, Button, message, Typography, AutoComplete } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUser } from '../contexts';
|
||||
import { userAPI } from '../services/api';
|
||||
import { validateUserForm } from '../utils/validation';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface LoginHistory {
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setUser } = useUser();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [historyOptions, setHistoryOptions] = useState<{ value: string; label: string; phone: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// 加载历史记录
|
||||
const history = JSON.parse(localStorage.getItem('loginHistory') || '[]');
|
||||
setHistoryOptions(history.map((item: LoginHistory) => ({
|
||||
value: item.name,
|
||||
label: item.name,
|
||||
phone: item.phone
|
||||
})));
|
||||
}, []);
|
||||
|
||||
const saveToHistory = (name: string, phone: string) => {
|
||||
const history: LoginHistory[] = JSON.parse(localStorage.getItem('loginHistory') || '[]');
|
||||
// 移除已存在的同名记录(为了更新位置到最前,或者保持最新)
|
||||
// 简单起见,如果已存在,先移除
|
||||
const filtered = history.filter(item => item.name !== name);
|
||||
// 添加到头部
|
||||
filtered.unshift({ name, phone });
|
||||
// 保留前5条
|
||||
const newHistory = filtered.slice(0, 5);
|
||||
localStorage.setItem('loginHistory', JSON.stringify(newHistory));
|
||||
};
|
||||
|
||||
const handleNameSelect = (value: string, option: any) => {
|
||||
if (option.phone) {
|
||||
form.setFieldsValue({ phone: option.phone });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { name: string; phone: string; password?: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 验证表单
|
||||
const validation = validateUserForm(values.name, values.phone);
|
||||
if (!validation.valid) {
|
||||
message.error(validation.nameError || validation.phoneError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建用户或登录
|
||||
const response = await userAPI.createUser(values) as any;
|
||||
|
||||
if (response.success) {
|
||||
setUser(response.data);
|
||||
saveToHistory(values.name, values.phone);
|
||||
message.success('登录成功,请选择考试科目');
|
||||
setTimeout(() => {
|
||||
navigate('/subjects');
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<div className="text-center mb-6">
|
||||
<Title level={2} className="text-blue-600">
|
||||
问卷调查系统
|
||||
</Title>
|
||||
<p className="text-gray-600 mt-2">
|
||||
请填写您的基本信息开始答题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: '请输入姓名' },
|
||||
{ min: 2, max: 20, message: '姓名长度必须在2-20个字符之间' },
|
||||
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s]+$/, message: '姓名只能包含中文、英文和空格' }
|
||||
]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={historyOptions}
|
||||
onSelect={handleNameSelect}
|
||||
placeholder="请输入您的姓名"
|
||||
size="large"
|
||||
filterOption={(inputValue, option) =>
|
||||
option!.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的中国手机号' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入11位手机号"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
maxLength={11}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="登录密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入登录密码' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入登录密码"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
autoComplete="new-password"
|
||||
visibilityToggle
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size="large"
|
||||
className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 border-none"
|
||||
>
|
||||
开始答题
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="/admin/login"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
管理员登录
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
371
src/pages/QuizPage.tsx
Normal file
371
src/pages/QuizPage.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Button, Radio, Checkbox, Input, message, Progress } from 'antd';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useUser, useQuiz } from '../contexts';
|
||||
import { quizAPI } from '../services/api';
|
||||
import { questionTypeMap } from '../utils/validation';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
content: string;
|
||||
type: 'single' | 'multiple' | 'judgment' | 'text';
|
||||
options?: string[];
|
||||
answer: string | string[];
|
||||
score: number;
|
||||
createdAt: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
questions?: Question[];
|
||||
totalScore?: number;
|
||||
timeLimit?: number;
|
||||
subjectId?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const QuizPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useUser();
|
||||
const { questions, setQuestions, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswer, clearQuiz } = useQuiz();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
||||
const [timeLimit, setTimeLimit] = useState<number | null>(null);
|
||||
const [subjectId, setSubjectId] = useState<string>('');
|
||||
const [taskId, setTaskId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
message.warning('请先填写个人信息');
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = location.state as LocationState;
|
||||
|
||||
if (state?.questions) {
|
||||
// 如果已经有题目数据(来自科目选择页面)
|
||||
setQuestions(state.questions);
|
||||
setTimeLimit(state.timeLimit || 60);
|
||||
setTimeLeft((state.timeLimit || 60) * 60); // 转换为秒
|
||||
setSubjectId(state.subjectId || '');
|
||||
setTaskId(state.taskId || '');
|
||||
setCurrentQuestionIndex(0);
|
||||
} else {
|
||||
// 兼容旧版本,直接生成题目
|
||||
generateQuiz();
|
||||
}
|
||||
|
||||
// 清除之前的答题状态
|
||||
clearQuiz();
|
||||
}, [user, navigate, location]);
|
||||
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
if (timeLeft === null || timeLeft <= 0) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(timer);
|
||||
handleTimeUp();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [timeLeft]);
|
||||
|
||||
const generateQuiz = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await quizAPI.generateQuiz(user!.id);
|
||||
setQuestions(response.data.questions);
|
||||
setCurrentQuestionIndex(0);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '生成试卷失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUp = () => {
|
||||
message.warning('考试时间已到,将自动提交答案');
|
||||
setTimeout(() => {
|
||||
handleSubmit(true);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getTagColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'multiple':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'judgment':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'text':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswerChange = (questionId: string, value: string | string[]) => {
|
||||
setAnswer(questionId, value);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentQuestionIndex > 0) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (forceSubmit = false) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
if (!forceSubmit) {
|
||||
// 检查是否所有题目都已回答
|
||||
const unansweredQuestions = questions.filter(q => !answers[q.id]);
|
||||
if (unansweredQuestions.length > 0) {
|
||||
message.warning(`还有 ${unansweredQuestions.length} 道题未作答`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 准备答案数据
|
||||
const answersData = questions.map(question => {
|
||||
const isCorrect = checkAnswer(question, answers[question.id]);
|
||||
return {
|
||||
questionId: question.id,
|
||||
userAnswer: answers[question.id],
|
||||
score: isCorrect ? question.score : 0,
|
||||
isCorrect
|
||||
};
|
||||
});
|
||||
|
||||
const response = await quizAPI.submitQuiz({
|
||||
userId: user!.id,
|
||||
subjectId: subjectId || undefined,
|
||||
taskId: taskId || undefined,
|
||||
answers: answersData
|
||||
});
|
||||
|
||||
message.success('答题提交成功!');
|
||||
navigate(`/result/${response.data.recordId}`);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAnswer = (question: Question, userAnswer: string | string[]): boolean => {
|
||||
if (!userAnswer) return false;
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
const correctAnswers = Array.isArray(question.answer) ? question.answer : [question.answer];
|
||||
const userAnswers = Array.isArray(userAnswer) ? userAnswer : [userAnswer];
|
||||
return correctAnswers.length === userAnswers.length &&
|
||||
correctAnswers.every(answer => userAnswers.includes(answer));
|
||||
} else {
|
||||
return userAnswer === question.answer;
|
||||
}
|
||||
};
|
||||
|
||||
const renderQuestion = (question: Question) => {
|
||||
const currentAnswer = answers[question.id];
|
||||
|
||||
switch (question.type) {
|
||||
case 'single':
|
||||
return (
|
||||
<Radio.Group
|
||||
value={currentAnswer as string}
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{question.options?.map((option, index) => (
|
||||
<Radio key={index} value={option} className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
{String.fromCharCode(65 + index)}. {option}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
);
|
||||
|
||||
case 'multiple':
|
||||
return (
|
||||
<Checkbox.Group
|
||||
value={currentAnswer as string[] || []}
|
||||
onChange={(checkedValues) => handleAnswerChange(question.id, checkedValues)}
|
||||
className="w-full"
|
||||
>
|
||||
{question.options?.map((option, index) => (
|
||||
<Checkbox key={index} value={option} className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors w-full">
|
||||
{String.fromCharCode(65 + index)}. {option}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
|
||||
case 'judgment':
|
||||
return (
|
||||
<Radio.Group
|
||||
value={currentAnswer as string}
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<Radio value="正确" className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
正确
|
||||
</Radio>
|
||||
<Radio value="错误" className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
错误
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<TextArea
|
||||
rows={6}
|
||||
value={currentAnswer as string || ''}
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
placeholder="请输入您的答案..."
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>未知题型</div>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !questions.length) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在生成试卷...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* 头部信息 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">在线答题</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
第 {currentQuestionIndex + 1} 题 / 共 {questions.length} 题
|
||||
</p>
|
||||
</div>
|
||||
{timeLeft !== null && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">剩余时间</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
timeLeft < 300 ? 'text-red-600' : 'text-green-600'
|
||||
}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
percent={Math.round(progress)}
|
||||
strokeColor="#3b82f6"
|
||||
showInfo={false}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 题目卡片 */}
|
||||
<Card className="shadow-sm">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${getTagColor(currentQuestion.type)}`}>
|
||||
{questionTypeMap[currentQuestion.type]}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentQuestion.score} 分
|
||||
</span>
|
||||
</div>
|
||||
{currentQuestion.category && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-block px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">
|
||||
{currentQuestion.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-medium text-gray-900 leading-relaxed">
|
||||
{currentQuestion.content}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
{renderQuestion(currentQuestion)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
className="px-6"
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
{currentQuestionIndex === questions.length - 1 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleSubmit()}
|
||||
loading={submitting}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
|
||||
>
|
||||
提交答案
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
|
||||
>
|
||||
下一题
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizPage;
|
||||
273
src/pages/ResultPage.tsx
Normal file
273
src/pages/ResultPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Result, Button, Descriptions, message } from 'antd';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useUser } from '../contexts';
|
||||
import { quizAPI } from '../services/api';
|
||||
import { formatDateTime } from '../utils/validation';
|
||||
|
||||
const { Item } = Descriptions;
|
||||
|
||||
interface QuizRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
totalScore: number;
|
||||
correctCount: number;
|
||||
totalCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface QuizAnswer {
|
||||
id: string;
|
||||
questionId: string;
|
||||
questionContent?: string;
|
||||
questionType?: string;
|
||||
userAnswer: string | string[];
|
||||
score: number;
|
||||
isCorrect: boolean;
|
||||
correctAnswer?: string | string[];
|
||||
questionScore?: number;
|
||||
}
|
||||
|
||||
const ResultPage = () => {
|
||||
const { id: recordId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUser();
|
||||
const [record, setRecord] = useState<QuizRecord | null>(null);
|
||||
const [answers, setAnswers] = useState<QuizAnswer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId) {
|
||||
message.error('无效的记录ID');
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchResultDetail();
|
||||
}, [recordId, navigate]);
|
||||
|
||||
const fetchResultDetail = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await quizAPI.getRecordDetail(recordId!);
|
||||
setRecord(response.data.record);
|
||||
setAnswers(response.data.answers);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取答题结果失败');
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToHome = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const getTagColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'multiple':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'judgment':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'text':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const parseAnswerList = (ans: string | string[] | undefined): string[] => {
|
||||
if (!ans) return [];
|
||||
if (Array.isArray(ans)) return ans;
|
||||
try {
|
||||
const parsed = JSON.parse(ans);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
return [ans];
|
||||
} catch {
|
||||
return [ans];
|
||||
}
|
||||
};
|
||||
|
||||
const renderUserAnswer = (answer: QuizAnswer) => {
|
||||
const userList = parseAnswerList(answer.userAnswer);
|
||||
const correctList = parseAnswerList(answer.correctAnswer);
|
||||
const correctSet = new Set(correctList);
|
||||
|
||||
if (answer.questionType === 'multiple') {
|
||||
return (
|
||||
<span className="font-medium">
|
||||
{userList.map((item, idx) => {
|
||||
const isWrong = !correctSet.has(item);
|
||||
return (
|
||||
<span key={idx} className={isWrong ? 'text-red-600' : 'text-gray-800'}>
|
||||
{idx > 0 && ', '}
|
||||
{item}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 非多选题,直接展示
|
||||
return (
|
||||
<span className={answer.isCorrect ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
|
||||
{Array.isArray(answer.userAnswer) ? answer.userAnswer.join(', ') : answer.userAnswer}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCorrectAnswer = (answer: QuizAnswer) => {
|
||||
const userList = parseAnswerList(answer.userAnswer);
|
||||
const correctList = parseAnswerList(answer.correctAnswer);
|
||||
const userSet = new Set(userList);
|
||||
|
||||
if (answer.questionType === 'multiple') {
|
||||
return (
|
||||
<span className="font-medium">
|
||||
{correctList.map((item, idx) => {
|
||||
const isMissed = !userSet.has(item); // 用户没选这个正确选项
|
||||
return (
|
||||
<span key={idx} className={isMissed ? 'text-red-600' : 'text-green-600'}>
|
||||
{idx > 0 && ', '}
|
||||
{item}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-green-600 font-medium">
|
||||
{Array.isArray(answer.correctAnswer)
|
||||
? answer.correctAnswer.join(', ')
|
||||
: answer.correctAnswer || '未知'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在加载答题结果...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-4">答题记录不存在</p>
|
||||
<Button type="primary" onClick={handleBackToHome}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const correctRate = ((record.correctCount / record.totalCount) * 100).toFixed(1);
|
||||
const status = record.totalScore >= record.totalCount * 0.6 ? 'success' : 'warning';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* 结果概览 */}
|
||||
<Card className="shadow-lg mb-8">
|
||||
<Result
|
||||
status={status as any}
|
||||
title={`答题完成!您的得分是 ${record.totalScore} 分`}
|
||||
subTitle={`正确率 ${correctRate}% (${record.correctCount}/${record.totalCount})`}
|
||||
extra={[
|
||||
<Button key="back" onClick={handleBackToHome} className="mr-4">
|
||||
返回首页
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Card className="shadow-lg mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4">答题信息</h3>
|
||||
<Descriptions bordered column={2}>
|
||||
<Item label="姓名">{user?.name}</Item>
|
||||
<Item label="手机号">{user?.phone}</Item>
|
||||
<Item label="答题时间">{formatDateTime(record.createdAt)}</Item>
|
||||
<Item label="总题数">{record.totalCount} 题</Item>
|
||||
<Item label="正确数">{record.correctCount} 题</Item>
|
||||
<Item label="总得分">{record.totalScore} 分</Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 答案详情 */}
|
||||
<Card className="shadow-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">答案详情</h3>
|
||||
<div className="space-y-4">
|
||||
{answers.map((answer, index) => (
|
||||
<div
|
||||
key={answer.id}
|
||||
className={`p-4 rounded-lg border-2 ${
|
||||
answer.isCorrect
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-red-200 bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800">
|
||||
第 {index + 1} 题
|
||||
</span>
|
||||
<span className={`${getTagColor(answer.questionType || '')} px-2 py-0.5 rounded text-xs`}>
|
||||
{answer.questionType === 'single' ? '单选题' :
|
||||
answer.questionType === 'multiple' ? '多选题' :
|
||||
answer.questionType === 'judgment' ? '判断题' : '简答题'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-sm ${
|
||||
answer.isCorrect
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{answer.isCorrect ? '正确' : '错误'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">题目:</span>
|
||||
<span className="text-gray-800">{answer.questionContent || '题目内容加载失败'}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">您的答案:</span>
|
||||
{renderUserAnswer(answer)}
|
||||
</div>
|
||||
{!answer.isCorrect && (
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">正确答案:</span>
|
||||
{renderCorrectAnswer(answer)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-600">得分详情:</span>
|
||||
<span className="text-gray-800">
|
||||
本题分值:{answer.questionScore || 0} 分,你的得分:{answer.score} 分
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultPage;
|
||||
290
src/pages/SubjectSelectionPage.tsx
Normal file
290
src/pages/SubjectSelectionPage.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Button, Typography, Tag, Space, Spin, message, Modal } from 'antd';
|
||||
import { ClockCircleOutlined, BookOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { request } from '../utils/request';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface ExamSubject {
|
||||
id: string;
|
||||
name: string;
|
||||
totalScore: number;
|
||||
timeLimitMinutes: number;
|
||||
typeRatios: Record<string, number>;
|
||||
categoryRatios: Record<string, number>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ExamTask {
|
||||
id: string;
|
||||
name: string;
|
||||
subjectId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
subjectName?: string;
|
||||
}
|
||||
|
||||
export const SubjectSelectionPage: React.FC = () => {
|
||||
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
|
||||
const [tasks, setTasks] = useState<ExamTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
||||
const [selectedTask, setSelectedTask] = useState<string>('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useUserStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
||||
// 如果从任务页面跳转过来,自动选择对应的任务
|
||||
const state = location.state as { selectedTask?: string };
|
||||
if (state?.selectedTask) {
|
||||
setSelectedTask(state.selectedTask);
|
||||
setSelectedSubject('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [subjectsRes, tasksRes] = await Promise.all([
|
||||
request.get('/api/exam-subjects'),
|
||||
request.get('/api/exam-tasks')
|
||||
]);
|
||||
|
||||
if (subjectsRes.data.success) {
|
||||
setSubjects(subjectsRes.data.data);
|
||||
}
|
||||
|
||||
if (tasksRes.data.success) {
|
||||
const now = new Date();
|
||||
const validTasks = tasksRes.data.data.filter((task: ExamTask) => {
|
||||
const startAt = new Date(task.startAt);
|
||||
const endAt = new Date(task.endAt);
|
||||
return now >= startAt && now <= endAt;
|
||||
});
|
||||
setTasks(validTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startQuiz = async () => {
|
||||
if (!selectedSubject && !selectedTask) {
|
||||
message.warning('请选择考试科目或考试任务');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.post('/api/quiz/generate', {
|
||||
userId: user?.id,
|
||||
subjectId: selectedSubject,
|
||||
taskId: selectedTask
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { questions, totalScore, timeLimit } = response.data.data;
|
||||
navigate('/quiz', {
|
||||
state: {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimit,
|
||||
subjectId: selectedSubject,
|
||||
taskId: selectedTask
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '生成试卷失败');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTypeRatio = (typeRatios: Record<string, number>) => {
|
||||
return Object.entries(typeRatios)
|
||||
.filter(([, ratio]) => ratio > 0)
|
||||
.map(([type, ratio]) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
single: '单选题',
|
||||
multiple: '多选题',
|
||||
truefalse: '判断题',
|
||||
fill: '填空题',
|
||||
essay: '问答题'
|
||||
};
|
||||
return `${typeMap[type] || type}: ${ratio}%`;
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<Title level={2} className="text-center mb-2">
|
||||
选择考试科目
|
||||
</Title>
|
||||
<Text type="secondary" className="text-center block">
|
||||
请选择您要参加的考试科目或考试任务
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 考试科目选择 */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<BookOutlined className="text-2xl mr-2 text-blue-600" />
|
||||
<Title level={3} className="mb-0">考试科目</Title>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{subjects.map((subject) => (
|
||||
<Card
|
||||
key={subject.id}
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
selectedSubject === subject.id
|
||||
? 'border-blue-500 shadow-lg bg-blue-50'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSubject(subject.id);
|
||||
setSelectedTask('');
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<Title level={4} className="mb-2">{subject.name}</Title>
|
||||
<Space direction="vertical" size="small" className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-2 text-gray-500" />
|
||||
<Text>{subject.timeLimitMinutes}分钟</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">总分:</span>
|
||||
<Text strong>{subject.totalScore}分</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="mb-1">题型分布:</div>
|
||||
<div>{formatTypeRatio(subject.typeRatios)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSubject === subject.id && (
|
||||
<div className="text-blue-600">
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 考试任务选择 */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<UserOutlined className="text-2xl mr-2 text-green-600" />
|
||||
<Title level={3} className="mb-0">考试任务</Title>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => {
|
||||
const subject = subjects.find(s => s.id === task.subjectId);
|
||||
return (
|
||||
<Card
|
||||
key={task.id}
|
||||
className={`cursor-pointer transition-all duration-200 ${
|
||||
selectedTask === task.id
|
||||
? 'border-green-500 shadow-lg bg-green-50'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTask(task.id);
|
||||
setSelectedSubject('');
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<Title level={4} className="mb-2">{task.name}</Title>
|
||||
<Space direction="vertical" size="small" className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<BookOutlined className="mr-2 text-gray-500" />
|
||||
<Text>{subject?.name || '未知科目'}</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-2 text-gray-500" />
|
||||
<Text>
|
||||
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</div>
|
||||
{subject && (
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">时长:</span>
|
||||
<Text>{subject.timeLimitMinutes}分钟</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
{subject && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="mb-1">题型分布:</div>
|
||||
<div>{formatTypeRatio(subject.typeRatios)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedTask === task.id && (
|
||||
<div className="text-green-600">
|
||||
<div className="w-6 h-6 bg-green-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<Card className="text-center py-8">
|
||||
<Text type="secondary">暂无可用考试任务</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center space-x-4">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="px-8 h-12 text-lg"
|
||||
onClick={startQuiz}
|
||||
disabled={!selectedSubject && !selectedTask}
|
||||
>
|
||||
开始考试
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
className="px-8 h-12 text-lg"
|
||||
onClick={() => navigate('/tasks')}
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
查看我的任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
225
src/pages/UserTaskPage.tsx
Normal file
225
src/pages/UserTaskPage.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Table, Tag, Button, Space, Spin, message, Typography } from 'antd';
|
||||
import { ClockCircleOutlined, BookOutlined, CalendarOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../utils/request';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface ExamTask {
|
||||
id: string;
|
||||
name: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
totalScore: number;
|
||||
timeLimitMinutes: number;
|
||||
completed?: boolean;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export const UserTaskPage: React.FC = () => {
|
||||
const [tasks, setTasks] = useState<ExamTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchUserTasks();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchUserTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await request.get(`/api/exam-tasks/user/${user?.id}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setTasks(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取考试任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startTask = (task: ExamTask) => {
|
||||
const now = new Date();
|
||||
const startAt = new Date(task.startAt);
|
||||
const endAt = new Date(task.endAt);
|
||||
|
||||
if (now < startAt) {
|
||||
message.warning('考试任务尚未开始');
|
||||
return;
|
||||
}
|
||||
|
||||
if (now > endAt) {
|
||||
message.warning('考试任务已结束');
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳转到科目选择页面,带上任务ID
|
||||
navigate('/subjects', { state: { selectedTask: task.id } });
|
||||
};
|
||||
|
||||
const getStatusColor = (task: ExamTask) => {
|
||||
const now = new Date();
|
||||
const startAt = new Date(task.startAt);
|
||||
const endAt = new Date(task.endAt);
|
||||
|
||||
if (now < startAt) return 'blue';
|
||||
if (now > endAt) return 'red';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
const getStatusText = (task: ExamTask) => {
|
||||
const now = new Date();
|
||||
const startAt = new Date(task.startAt);
|
||||
const endAt = new Date(task.endAt);
|
||||
|
||||
if (now < startAt) return '未开始';
|
||||
if (now > endAt) return '已结束';
|
||||
return '进行中';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => <Text strong>{text}</Text>
|
||||
},
|
||||
{
|
||||
title: '考试科目',
|
||||
dataIndex: 'subjectName',
|
||||
key: 'subjectName',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<BookOutlined className="text-blue-600" />
|
||||
<Text>{text}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '总分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
render: (score: number) => <Text strong>{score}分</Text>
|
||||
},
|
||||
{
|
||||
title: '时长',
|
||||
dataIndex: 'timeLimitMinutes',
|
||||
key: 'timeLimitMinutes',
|
||||
render: (minutes: number) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined className="text-gray-600" />
|
||||
<Text>{minutes}分钟</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '时间范围',
|
||||
key: 'timeRange',
|
||||
render: (record: ExamTask) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-gray-600" />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{new Date(record.startAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-gray-600" />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{new Date(record.endAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (record: ExamTask) => (
|
||||
<Tag color={getStatusColor(record)}>
|
||||
{getStatusText(record)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (record: ExamTask) => {
|
||||
const now = new Date();
|
||||
const startAt = new Date(record.startAt);
|
||||
const endAt = new Date(record.endAt);
|
||||
const canStart = now >= startAt && now <= endAt;
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => startTask(record)}
|
||||
disabled={!canStart}
|
||||
icon={<CheckCircleOutlined />}
|
||||
>
|
||||
{canStart ? '开始考试' : '不可用'}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<Title level={2} className="text-center mb-2">
|
||||
我的考试任务
|
||||
</Title>
|
||||
<Text type="secondary" className="text-center block">
|
||||
查看您被分派的所有考试任务
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
}}
|
||||
locale={{
|
||||
emptyText: '暂无考试任务'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => navigate('/subjects')}
|
||||
icon={<BookOutlined />}
|
||||
>
|
||||
返回科目选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
191
src/pages/admin/AdminDashboardPage.tsx
Normal file
191
src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { formatDateTime } from '../../utils/validation';
|
||||
|
||||
interface Statistics {
|
||||
totalUsers: number;
|
||||
totalRecords: number;
|
||||
averageScore: number;
|
||||
typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>;
|
||||
}
|
||||
|
||||
interface RecentRecord {
|
||||
id: string;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
totalScore: number;
|
||||
correctCount: number;
|
||||
totalCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const AdminDashboardPage = () => {
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const statsResponse = await adminAPI.getStatistics();
|
||||
setStatistics(statsResponse.data);
|
||||
|
||||
// 获取最近10条答题记录
|
||||
const recordsResponse = await fetchRecentRecords();
|
||||
setRecentRecords(recordsResponse);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 columns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'userPhone',
|
||||
key: 'userPhone',
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
render: (score: number) => <span className="font-semibold text-blue-600">{score} 分</span>,
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
key: 'correctRate',
|
||||
render: (_: any, record: RecentRecord) => {
|
||||
const rate = record.totalCount > 0
|
||||
? ((record.correctCount / record.totalCount) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
return <span>{rate}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '答题时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => formatDateTime(date),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">管理仪表盘</h1>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchDashboardData}
|
||||
loading={loading}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm">
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={statistics?.totalUsers || 0}
|
||||
prefix={<UserOutlined className="text-blue-500" />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm">
|
||||
<Statistic
|
||||
title="答题记录"
|
||||
value={statistics?.totalRecords || 0}
|
||||
prefix={<BarChartOutlined className="text-green-500" />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm">
|
||||
<Statistic
|
||||
title="平均得分"
|
||||
value={statistics?.averageScore || 0}
|
||||
precision={1}
|
||||
prefix={<QuestionCircleOutlined className="text-orange-500" />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
suffix="分"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 题型正确率统计 */}
|
||||
{statistics?.typeStats && statistics.typeStats.length > 0 && (
|
||||
<Card title="题型正确率统计" className="mb-8 shadow-sm">
|
||||
<Row gutter={16}>
|
||||
{statistics.typeStats.map((stat) => (
|
||||
<Col span={6} key={stat.type}>
|
||||
<Card size="small" className="text-center">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{stat.type === 'single' && '单选题'}
|
||||
{stat.type === 'multiple' && '多选题'}
|
||||
{stat.type === 'judgment' && '判断题'}
|
||||
{stat.type === 'text' && '文字题'}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stat.correctRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{stat.correct}/{stat.total}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 最近答题记录 */}
|
||||
<Card title="最近答题记录" className="shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={recentRecords}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
103
src/pages/admin/AdminLoginPage.tsx
Normal file
103
src/pages/admin/AdminLoginPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, Button, message } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAdmin } from '../../contexts';
|
||||
import { adminAPI } from '../../services/api';
|
||||
|
||||
const AdminLoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setAdmin } = useAdmin();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminAPI.login(values) as any;
|
||||
|
||||
if (response.success) {
|
||||
setAdmin({
|
||||
username: values.username,
|
||||
token: response.data.token
|
||||
});
|
||||
message.success('登录成功');
|
||||
navigate('/admin/dashboard');
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-blue-600 mb-2">管理员登录</h1>
|
||||
<p className="text-gray-600">请输入管理员账号密码</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size="large"
|
||||
className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 border-none"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
返回用户端
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
219
src/pages/admin/BackupRestorePage.tsx
Normal file
219
src/pages/admin/BackupRestorePage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Table, Button, message, Upload, Modal } from 'antd';
|
||||
import { UploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const BackupRestorePage = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 数据备份
|
||||
const handleBackup = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 获取所有数据
|
||||
const [users, questions, records, answers] = await Promise.all([
|
||||
fetch('/api/admin/export/users').then(res => res.json()),
|
||||
fetch('/api/admin/export/questions').then(res => res.json()),
|
||||
fetch('/api/admin/export/records').then(res => res.json()),
|
||||
fetch('/api/admin/export/answers').then(res => res.json())
|
||||
]);
|
||||
|
||||
// 创建工作簿
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// 添加工作表
|
||||
if (users.success && users.data) {
|
||||
const usersWS = XLSX.utils.json_to_sheet(users.data);
|
||||
XLSX.utils.book_append_sheet(workbook, usersWS, '用户数据');
|
||||
}
|
||||
|
||||
if (questions.success && questions.data) {
|
||||
const questionsWS = XLSX.utils.json_to_sheet(questions.data);
|
||||
XLSX.utils.book_append_sheet(workbook, questionsWS, '题库数据');
|
||||
}
|
||||
|
||||
if (records.success && records.data) {
|
||||
const recordsWS = XLSX.utils.json_to_sheet(records.data);
|
||||
XLSX.utils.book_append_sheet(workbook, recordsWS, '答题记录');
|
||||
}
|
||||
|
||||
if (answers.success && answers.data) {
|
||||
const answersWS = XLSX.utils.json_to_sheet(answers.data);
|
||||
XLSX.utils.book_append_sheet(workbook, answersWS, '答题答案');
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const fileName = `问卷系统备份_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
|
||||
message.success('数据备份成功');
|
||||
} catch (error) {
|
||||
console.error('备份失败:', error);
|
||||
message.error('数据备份失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 数据恢复
|
||||
const handleRestore = async (file: File) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
// 解析各个工作表
|
||||
const sheetNames = workbook.SheetNames;
|
||||
const restoreData: any = {};
|
||||
|
||||
sheetNames.forEach(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
if (sheetName.includes('用户')) {
|
||||
restoreData.users = jsonData;
|
||||
} else if (sheetName.includes('题库')) {
|
||||
restoreData.questions = jsonData;
|
||||
} else if (sheetName.includes('记录')) {
|
||||
restoreData.records = jsonData;
|
||||
} else if (sheetName.includes('答案')) {
|
||||
restoreData.answers = jsonData;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示恢复确认对话框
|
||||
Modal.confirm({
|
||||
title: '确认数据恢复',
|
||||
content: (
|
||||
<div>
|
||||
<p>检测到以下数据:</p>
|
||||
<ul>
|
||||
{restoreData.users && <li>用户数据:{restoreData.users.length} 条</li>}
|
||||
{restoreData.questions && <li>题库数据:{restoreData.questions.length} 条</li>}
|
||||
{restoreData.records && <li>答题记录:{restoreData.records.length} 条</li>}
|
||||
{restoreData.answers && <li>答题答案:{restoreData.answers.length} 条</li>}
|
||||
</ul>
|
||||
<p style={{ color: 'red', marginTop: 16 }}>
|
||||
警告:数据恢复将覆盖现有数据,请确保已备份当前数据!
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
await performRestore(restoreData);
|
||||
},
|
||||
width: 500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('解析文件失败:', error);
|
||||
message.error('文件解析失败,请检查文件格式');
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (error) {
|
||||
console.error('恢复失败:', error);
|
||||
message.error('数据恢复失败');
|
||||
}
|
||||
|
||||
return false; // 阻止上传
|
||||
};
|
||||
|
||||
// 执行数据恢复
|
||||
const performRestore = async (data: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 调用恢复API
|
||||
const response = await fetch('/api/admin/restore', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.success('数据恢复成功');
|
||||
} else {
|
||||
message.error(result.message || '数据恢复失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复失败:', error);
|
||||
message.error('数据恢复失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">数据备份与恢复</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 数据备份 */}
|
||||
<Card title="数据备份" className="shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
备份所有数据到Excel文件,包括用户信息、题库、答题记录等。
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBackup}
|
||||
loading={loading}
|
||||
size="large"
|
||||
className="w-full"
|
||||
>
|
||||
立即备份
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 数据恢复 */}
|
||||
<Card title="数据恢复" className="shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
从Excel文件恢复数据,将覆盖现有数据,请谨慎操作。
|
||||
</p>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleRestore}
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={loading}
|
||||
size="large"
|
||||
className="w-full"
|
||||
danger
|
||||
>
|
||||
选择文件恢复
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 注意事项 */}
|
||||
<Card title="注意事项" className="mt-6 shadow-sm">
|
||||
<div className="space-y-2 text-gray-600">
|
||||
<p>• 建议定期进行数据备份,以防数据丢失</p>
|
||||
<p>• 恢复数据前请确保已备份当前数据</p>
|
||||
<p>• 数据恢复操作不可撤销,请谨慎操作</p>
|
||||
<p>• 备份文件包含所有数据,请妥善保管</p>
|
||||
<p>• 建议在系统空闲时进行数据恢复操作</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupRestorePage;
|
||||
356
src/pages/admin/ExamSubjectPage.tsx
Normal file
356
src/pages/admin/ExamSubjectPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface ExamSubject {
|
||||
id: string;
|
||||
name: string;
|
||||
totalScore: number;
|
||||
timeLimitMinutes: number;
|
||||
typeRatios: Record<string, number>;
|
||||
categoryRatios: Record<string, number>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface QuestionCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ExamSubjectPage = () => {
|
||||
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
|
||||
const [categories, setCategories] = useState<QuestionCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingSubject, setEditingSubject] = useState<ExamSubject | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 题型配置
|
||||
const questionTypes = [
|
||||
{ key: 'single', label: '单选题', color: '#52c41a' },
|
||||
{ key: 'multiple', label: '多选题', color: '#faad14' },
|
||||
{ key: 'judgment', label: '判断题', color: '#ff4d4f' },
|
||||
{ key: 'text', label: '文字题', color: '#1890ff' },
|
||||
];
|
||||
|
||||
const fetchSubjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [subjectsRes, categoriesRes] = await Promise.all([
|
||||
api.get('/admin/subjects'),
|
||||
api.get('/question-categories')
|
||||
]);
|
||||
setSubjects(subjectsRes.data);
|
||||
setCategories(categoriesRes.data);
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubjects();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingSubject(null);
|
||||
form.resetFields();
|
||||
// 设置默认值
|
||||
form.setFieldsValue({
|
||||
typeRatios: { single: 40, multiple: 30, judgment: 20, text: 10 },
|
||||
categoryRatios: { 通用: 100 },
|
||||
totalScore: 100,
|
||||
timeLimitMinutes: 60,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (subject: ExamSubject) => {
|
||||
setEditingSubject(subject);
|
||||
form.setFieldsValue({
|
||||
name: subject.name,
|
||||
totalScore: subject.totalScore,
|
||||
timeLimitMinutes: subject.timeLimitMinutes,
|
||||
typeRatios: subject.typeRatios,
|
||||
categoryRatios: subject.categoryRatios,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/admin/subjects/${id}`);
|
||||
message.success('删除成功');
|
||||
fetchSubjects();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 验证题型比重总和
|
||||
const typeTotal = Object.values(values.typeRatios).reduce((sum: number, val) => sum + val, 0);
|
||||
if (typeTotal !== 100) {
|
||||
message.error('题型比重总和必须为100%');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证类别比重总和
|
||||
const categoryTotal = Object.values(values.categoryRatios).reduce((sum: number, val) => sum + val, 0);
|
||||
if (categoryTotal !== 100) {
|
||||
message.error('题目类别比重总和必须为100%');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingSubject) {
|
||||
await api.put(`/admin/subjects/${editingSubject.id}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await api.post('/admin/subjects', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchSubjects();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeRatioChange = (type: string, value: number) => {
|
||||
const currentRatios = form.getFieldValue('typeRatios') || {};
|
||||
const newRatios = { ...currentRatios, [type]: value };
|
||||
form.setFieldsValue({ typeRatios: newRatios });
|
||||
};
|
||||
|
||||
const handleCategoryRatioChange = (category: string, value: number) => {
|
||||
const currentRatios = form.getFieldValue('categoryRatios') || {};
|
||||
const newRatios = { ...currentRatios, [category]: value };
|
||||
form.setFieldsValue({ categoryRatios: newRatios });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '科目名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '总分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
render: (score: number) => `${score} 分`,
|
||||
},
|
||||
{
|
||||
title: '答题时间',
|
||||
dataIndex: 'timeLimitMinutes',
|
||||
key: 'timeLimitMinutes',
|
||||
render: (minutes: number) => `${minutes} 分钟`,
|
||||
},
|
||||
{
|
||||
title: '题型分布',
|
||||
dataIndex: 'typeRatios',
|
||||
key: 'typeRatios',
|
||||
render: (ratios: Record<string, number>) => (
|
||||
<div className="space-y-1">
|
||||
{ratios && Object.entries(ratios).map(([type, ratio]) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type);
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between text-sm">
|
||||
<span>{typeConfig?.label || type}</span>
|
||||
<span className="font-medium">{ratio}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: ExamSubject) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除该科目吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">考试科目管理</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增科目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={subjects}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingSubject ? '编辑科目' : '新增科目'}
|
||||
open={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="科目名称"
|
||||
rules={[{ required: true, message: '请输入科目名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入科目名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="totalScore"
|
||||
label="试卷总分"
|
||||
rules={[{ required: true, message: '请输入试卷总分' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={200}
|
||||
placeholder="请输入总分"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="timeLimitMinutes"
|
||||
label="答题时间(分钟)"
|
||||
rules={[{ required: true, message: '请输入答题时间' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={180}
|
||||
placeholder="请输入时间"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="题型比重配置" className="mb-4">
|
||||
<Form.Item name="typeRatios" noStyle>
|
||||
<div className="space-y-4">
|
||||
{questionTypes.map((type) => {
|
||||
const currentRatios = form.getFieldValue('typeRatios') || {};
|
||||
const ratio = currentRatios[type.key] || 0;
|
||||
return (
|
||||
<div key={type.key}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<span className="text-blue-600 font-bold">{ratio}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={ratio}
|
||||
onChange={(value) => handleTypeRatioChange(type.key, value || 0)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Progress
|
||||
percent={ratio}
|
||||
strokeColor={type.color}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-right text-sm text-gray-600">
|
||||
总计:{Object.values(form.getFieldValue('typeRatios') || {}).reduce((sum: number, val) => sum + val, 0)}%
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="题目类别比重配置">
|
||||
<Form.Item name="categoryRatios" noStyle>
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const currentRatios = form.getFieldValue('categoryRatios') || {};
|
||||
const ratio = currentRatios[category.name] || 0;
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<span className="text-blue-600 font-bold">{ratio}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={ratio}
|
||||
onChange={(value) => handleCategoryRatioChange(category.name, value || 0)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Progress
|
||||
percent={ratio}
|
||||
strokeColor="#1890ff"
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-right text-sm text-gray-600">
|
||||
总计:{Object.values(form.getFieldValue('categoryRatios') || {}).reduce((sum: number, val) => sum + val, 0)}%
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamSubjectPage;
|
||||
334
src/pages/admin/ExamTaskPage.tsx
Normal file
334
src/pages/admin/ExamTaskPage.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface ExamTask {
|
||||
id: string;
|
||||
name: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
userCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ExamSubject {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
const ExamTaskPage = () => {
|
||||
const [tasks, setTasks] = useState<ExamTask[]>([]);
|
||||
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [reportModalVisible, setReportModalVisible] = useState(false);
|
||||
const [reportData, setReportData] = useState<any>(null);
|
||||
const [editingTask, setEditingTask] = useState<ExamTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tasksRes, subjectsRes, usersRes] = await Promise.all([
|
||||
api.get('/admin/tasks'),
|
||||
api.get('/admin/subjects'),
|
||||
api.get('/admin/users'),
|
||||
]);
|
||||
setTasks(tasksRes.data);
|
||||
setSubjects(subjectsRes.data);
|
||||
setUsers(usersRes.data);
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingTask(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (task: ExamTask) => {
|
||||
setEditingTask(task);
|
||||
form.setFieldsValue({
|
||||
name: task.name,
|
||||
subjectId: task.subjectId,
|
||||
startAt: dayjs(task.startAt),
|
||||
endAt: dayjs(task.endAt),
|
||||
userIds: [],
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/admin/tasks/${id}`);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReport = async (taskId: string) => {
|
||||
try {
|
||||
const res = await api.get(`/admin/tasks/${taskId}/report`);
|
||||
setReportData(res.data);
|
||||
setReportModalVisible(true);
|
||||
} catch (error) {
|
||||
message.error('获取报表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
...values,
|
||||
startAt: values.startAt.toISOString(),
|
||||
endAt: values.endAt.toISOString(),
|
||||
};
|
||||
|
||||
if (editingTask) {
|
||||
await api.put(`/admin/tasks/${editingTask.id}`, payload);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await api.post('/admin/tasks', payload);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '考试科目',
|
||||
dataIndex: 'subjectName',
|
||||
key: 'subjectName',
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startAt',
|
||||
key: 'startAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endAt',
|
||||
key: 'endAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '参与人数',
|
||||
dataIndex: 'userCount',
|
||||
key: 'userCount',
|
||||
render: (count: number) => `${count} 人`,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: ExamTask) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleReport(record.id)}
|
||||
>
|
||||
报表
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除该任务吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">考试任务管理</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingTask ? '编辑任务' : '新增任务'}
|
||||
open={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="任务名称"
|
||||
rules={[{ required: true, message: '请输入任务名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="subjectId"
|
||||
label="考试科目"
|
||||
rules={[{ required: true, message: '请选择考试科目' }]}
|
||||
>
|
||||
<Select placeholder="请选择考试科目">
|
||||
{subjects.map((subject) => (
|
||||
<Select.Option key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="startAt"
|
||||
label="开始时间"
|
||||
rules={[{ required: true, message: '请选择开始时间' }]}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
placeholder="请选择开始时间"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="endAt"
|
||||
label="结束时间"
|
||||
rules={[{ required: true, message: '请选择结束时间' }]}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
placeholder="请选择结束时间"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="userIds"
|
||||
label="参与用户"
|
||||
rules={[{ required: true, message: '请选择参与用户' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择参与用户"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Option key={user.id} value={user.id}>
|
||||
{user.name} ({user.phone})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="任务报表"
|
||||
open={reportModalVisible}
|
||||
onCancel={() => setReportModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
{reportData && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold">{reportData.taskName}</h3>
|
||||
<p>考试科目:{reportData.subjectName}</p>
|
||||
<p>参与人数:{reportData.totalUsers} 人</p>
|
||||
<p>完成人数:{reportData.completedUsers} 人</p>
|
||||
<p>平均得分:{reportData.averageScore.toFixed(2)} 分</p>
|
||||
<p>最高得分:{reportData.topScore} 分</p>
|
||||
<p>最低得分:{reportData.lowestScore} 分</p>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'userName', key: 'userName' },
|
||||
{ title: '手机号', dataIndex: 'userPhone', key: 'userPhone' },
|
||||
{
|
||||
title: '得分',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
render: (score: number | null) => score !== null ? `${score} 分` : '未答题',
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'completedAt',
|
||||
key: 'completedAt',
|
||||
render: (date: string | null) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '未答题',
|
||||
},
|
||||
]}
|
||||
dataSource={reportData.details}
|
||||
rowKey="userId"
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamTaskPage;
|
||||
154
src/pages/admin/QuestionCategoryPage.tsx
Normal file
154
src/pages/admin/QuestionCategoryPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface QuestionCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const QuestionCategoryPage = () => {
|
||||
const [categories, setCategories] = useState<QuestionCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<QuestionCategory | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/question-categories');
|
||||
setCategories(res.data);
|
||||
} catch (error) {
|
||||
message.error('获取题目类别失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingCategory(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (category: QuestionCategory) => {
|
||||
setEditingCategory(category);
|
||||
form.setFieldsValue({ name: category.name });
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/admin/question-categories/${id}`);
|
||||
message.success('删除成功');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingCategory) {
|
||||
await api.put(`/admin/question-categories/${editingCategory.id}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await api.post('/admin/question-categories', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类别名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: QuestionCategory) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除该类别吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">题目类别管理</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增类别
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={categories}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingCategory ? '编辑类别' : '新增类别'}
|
||||
open={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="类别名称"
|
||||
rules={[{ required: true, message: '请输入类别名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入类别名称" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionCategoryPage;
|
||||
666
src/pages/admin/QuestionManagePage.tsx
Normal file
666
src/pages/admin/QuestionManagePage.tsx
Normal file
@@ -0,0 +1,666 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Upload,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Col,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Checkbox,
|
||||
DatePicker
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { questionAPI } from '../../services/api';
|
||||
import { questionTypeMap, questionTypeColors } from '../../utils/validation';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
content: string;
|
||||
type: 'single' | 'multiple' | 'judgment' | 'text';
|
||||
options?: string[];
|
||||
answer: string | string[];
|
||||
score: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const QuestionManagePage = () => {
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
// 筛选条件
|
||||
const [searchType, setSearchType] = useState<string>('');
|
||||
const [searchCategory, setSearchCategory] = useState<string>('');
|
||||
const [searchKeyword, setSearchKeyword] = useState<string>('');
|
||||
const [searchDateRange, setSearchDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
// 动态选项
|
||||
const [availableTypes, setAvailableTypes] = useState<string[]>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuestions();
|
||||
}, [pagination.current, pagination.pageSize, searchType, searchCategory, searchKeyword, searchDateRange]);
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await questionAPI.getQuestions({
|
||||
type: searchType,
|
||||
category: searchCategory,
|
||||
keyword: searchKeyword,
|
||||
startDate: searchDateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: searchDateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
});
|
||||
|
||||
setQuestions(response.data);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: (response as any).pagination.total
|
||||
}));
|
||||
|
||||
// 提取并更新可用的题型和类别列表
|
||||
const allQuestions = await questionAPI.getQuestions({ limit: 10000 });
|
||||
const types = [...new Set(allQuestions.data.map((q: any) => q.type))];
|
||||
const categories = [...new Set(allQuestions.data.map((q: any) => q.category || '通用'))];
|
||||
setAvailableTypes(types);
|
||||
setAvailableCategories(categories);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取题目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingQuestion(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (question: Question) => {
|
||||
setEditingQuestion(question);
|
||||
form.setFieldsValue({
|
||||
...question,
|
||||
options: question.options?.join('\n')
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await questionAPI.deleteQuestion(id);
|
||||
message.success('删除成功');
|
||||
fetchQuestions();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
const formData = {
|
||||
...values,
|
||||
options: values.options ? values.options.split('\n').filter((opt: string) => opt.trim()) : undefined
|
||||
};
|
||||
|
||||
if (editingQuestion) {
|
||||
await questionAPI.updateQuestion(editingQuestion.id, formData);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await questionAPI.createQuestion(formData);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchQuestions();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 导入题目
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
// 创建一个 FileReader 来读取 Excel 文件
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
// 使用顶部导入的 XLSX 对象,避免动态导入的问题
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// 转换为 JSON 数据
|
||||
const rawData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
// 解析数据
|
||||
const questions = rawData.map((row: any) => ({
|
||||
content: row['题目内容'] || row['content'],
|
||||
type: row['题型'] || row['type'],
|
||||
category: row['题目类别'] || row['category'] || '通用',
|
||||
answer: row['标准答案'] || row['answer'],
|
||||
score: parseInt(row['分值'] || row['score']) || 0,
|
||||
options: row['选项'] || row['options']
|
||||
}));
|
||||
|
||||
// 检查重复题目
|
||||
const existingQuestions = await Promise.all(
|
||||
questions.map(async (q: any) => {
|
||||
try {
|
||||
// 获取所有题目,然后在前端检查重复
|
||||
const response = await questionAPI.getQuestions({ limit: 10000 });
|
||||
const found = response.data.find((existing: any) => existing.content === q.content);
|
||||
return { ...q, existing: found };
|
||||
} catch (error) {
|
||||
return { ...q, existing: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 分离已存在和不存在的题目
|
||||
const existing = existingQuestions.filter(q => q.existing);
|
||||
const newQuestions = existingQuestions.filter(q => !q.existing);
|
||||
|
||||
// 如果没有重复题目,直接导入
|
||||
if (existing.length === 0) {
|
||||
await importQuestions(existingQuestions);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有重复题目,弹出确认框
|
||||
const modal = Modal.confirm({
|
||||
title: '导入确认',
|
||||
content: (
|
||||
<div>
|
||||
<p>发现 {existing.length} 道题目已存在,{newQuestions.length} 道新题目</p>
|
||||
<p>请选择处理方式:</p>
|
||||
<Radio.Group defaultValue="skip">
|
||||
<Radio value="skip">跳过已存在的题目</Radio>
|
||||
<Radio value="overwrite">覆盖已存在的题目</Radio>
|
||||
<Radio value="cancel">放弃导入</Radio>
|
||||
</Radio.Group>
|
||||
<Checkbox defaultChecked={false} id="applyAll">
|
||||
应用本次导入所有题目
|
||||
</Checkbox>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const checkbox = document.getElementById('applyAll') as HTMLInputElement;
|
||||
const applyAll = checkbox?.checked || false;
|
||||
const radios = document.querySelectorAll('input[type="radio"]');
|
||||
let option = 'skip';
|
||||
radios.forEach(radio => {
|
||||
if ((radio as HTMLInputElement).checked) {
|
||||
option = (radio as HTMLInputElement).value;
|
||||
}
|
||||
});
|
||||
|
||||
if (option === 'cancel') {
|
||||
message.info('已取消导入');
|
||||
return;
|
||||
}
|
||||
|
||||
let questionsToImport = existingQuestions;
|
||||
if (option === 'skip') {
|
||||
questionsToImport = newQuestions;
|
||||
}
|
||||
|
||||
await importQuestions(questionsToImport);
|
||||
modal.destroy(); // 关闭弹窗
|
||||
} catch (error) {
|
||||
modal.destroy(); // 关闭弹窗
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
message.info('已取消导入');
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('导入失败:', error);
|
||||
message.error(error.message || '导入失败');
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '导入失败');
|
||||
}
|
||||
return false; // 阻止上传
|
||||
};
|
||||
|
||||
// 实际导入题目
|
||||
const importQuestions = async (questions: any[]) => {
|
||||
try {
|
||||
// 将题目数据转换为 FormData
|
||||
const formData = new FormData();
|
||||
|
||||
// 创建一个新的 Excel 文件
|
||||
// 使用顶部导入的 XLSX 对象,避免动态导入的问题
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.json_to_sheet(questions);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, '题目列表');
|
||||
|
||||
// 将 workbook 转换为 blob
|
||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const file = new File([blob], 'temp_import.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
// 调用导入 API
|
||||
const response = await questionAPI.importQuestions(file);
|
||||
message.success(`成功导入 ${response.data.imported} 道题`);
|
||||
if (response.data.errors.length > 0) {
|
||||
message.warning(`有 ${response.data.errors.length} 道题导入失败`);
|
||||
}
|
||||
fetchQuestions();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '导入失败');
|
||||
console.error('导入失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出题目为Excel
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 使用现有的API获取题目数据
|
||||
let exportUrl = '/api/admin/export/questions';
|
||||
if (searchType) {
|
||||
exportUrl += `?type=${encodeURIComponent(searchType)}`;
|
||||
}
|
||||
|
||||
// 获取题目数据
|
||||
const response = await fetch(exportUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('survey_admin') ? `Bearer ${JSON.parse(localStorage.getItem('survey_admin') || '{}').token}` : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('导出失败');
|
||||
}
|
||||
|
||||
// 获取JSON数据
|
||||
const result = await response.json();
|
||||
|
||||
// 检查结果格式
|
||||
if (!result || !result.success || !result.data) {
|
||||
throw new Error('导出失败:数据格式错误');
|
||||
}
|
||||
|
||||
const questionsData = result.data;
|
||||
|
||||
// 创建工作簿和工作表
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.json_to_sheet(questionsData);
|
||||
|
||||
// 设置列宽
|
||||
const columnWidths = [
|
||||
{ wch: 10 }, // ID
|
||||
{ wch: 60 }, // 题目内容
|
||||
{ wch: 10 }, // 题型
|
||||
{ wch: 15 }, // 题目类别
|
||||
{ wch: 80 }, // 选项
|
||||
{ wch: 20 }, // 标准答案
|
||||
{ wch: 8 }, // 分值
|
||||
{ wch: 20 } // 创建时间
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
// 将工作表添加到工作簿
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, '题目列表');
|
||||
|
||||
// 生成Excel文件
|
||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `题库导出_${new Date().getTime()}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 清理
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(link);
|
||||
|
||||
message.success('导出成功');
|
||||
} catch (error: any) {
|
||||
console.error('导出失败:', error);
|
||||
message.error(error.message || '导出失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (_: any, __: any, index: number) => index + 1,
|
||||
},
|
||||
{
|
||||
title: '题目内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
width: '30%',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (type: string) => (
|
||||
<Tag color={questionTypeColors[type as keyof typeof questionTypeColors]}>
|
||||
{questionTypeMap[type as keyof typeof questionTypeMap]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '题目类别',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (category: string) => <span>{category || '通用'}</span>,
|
||||
},
|
||||
{
|
||||
title: '分值',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 80,
|
||||
render: (score: number) => <span className="font-semibold">{score} 分</span>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: Question) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
size="small"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除这道题吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">题库管理</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
{/* 题型筛选 - 动态生成选项 */}
|
||||
<Select
|
||||
placeholder="筛选题型"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={searchType}
|
||||
onChange={setSearchType}
|
||||
>
|
||||
<Option value="">全部</Option>
|
||||
{availableTypes.map(type => (
|
||||
<Option key={type} value={type}>
|
||||
{questionTypeMap[type as keyof typeof questionTypeMap] || type}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* 题目类别筛选 - 动态生成选项 */}
|
||||
<Select
|
||||
placeholder="筛选类别"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={searchCategory}
|
||||
onChange={setSearchCategory}
|
||||
>
|
||||
<Option value="">全部</Option>
|
||||
{availableCategories.map(category => (
|
||||
<Option key={category} value={category}>
|
||||
{category}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* 创建时间筛选 */}
|
||||
<DatePicker.RangePicker
|
||||
placeholder={['开始时间', '结束时间']}
|
||||
value={searchDateRange}
|
||||
onChange={setSearchDateRange}
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
|
||||
{/* 关键字搜索 */}
|
||||
<Input
|
||||
placeholder="关键字搜索"
|
||||
style={{ width: 200 }}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onPressEnter={fetchQuestions}
|
||||
/>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchQuestions}>
|
||||
刷新
|
||||
</Button>
|
||||
|
||||
{/* 导入导出按钮 */}
|
||||
<Space>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleImport}
|
||||
>
|
||||
<Button icon={<DownloadOutlined />}>Excel导入</Button>
|
||||
</Upload>
|
||||
<Button icon={<UploadOutlined />} onClick={handleExport}>
|
||||
Excel导出
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* 新增题目按钮 */}
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增题目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={questions}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
onChange={(newPagination) => setPagination(newPagination as any)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 编辑/新增模态框 */}
|
||||
<Modal
|
||||
title={editingQuestion ? '编辑题目' : '新增题目'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ score: 10 }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="题型"
|
||||
rules={[{ required: true, message: '请选择题型' }]}
|
||||
>
|
||||
<Select placeholder="选择题型">
|
||||
<Option value="single">单选题</Option>
|
||||
<Option value="multiple">多选题</Option>
|
||||
<Option value="judgment">判断题</Option>
|
||||
<Option value="text">文字描述题</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="score"
|
||||
label="分值"
|
||||
rules={[{ required: true, message: '请输入分值' }]}
|
||||
>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="题目内容"
|
||||
rules={[{ required: true, message: '请输入题目内容' }]}
|
||||
>
|
||||
<TextArea rows={3} placeholder="请输入题目内容" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
if (type === 'single' || type === 'multiple') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="options"
|
||||
label="选项(每行一个)"
|
||||
rules={[{ required: true, message: '请输入选项' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="请输入选项,每行一个,例如:\n选项A\n选项B\n选项C\n选项D"
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
if (type === 'judgment') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="answer"
|
||||
label="正确答案"
|
||||
rules={[{ required: true, message: '请选择正确答案' }]}
|
||||
>
|
||||
<Select placeholder="选择正确答案">
|
||||
<Option value="正确">正确</Option>
|
||||
<Option value="错误">错误</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
name="answer"
|
||||
label="正确答案"
|
||||
rules={[{ required: true, message: '请输入正确答案' }]}
|
||||
>
|
||||
<Input placeholder={type === 'multiple' ? '多个答案用逗号分隔' : '请输入正确答案'} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Space className="flex justify-end">
|
||||
<Button onClick={() => setModalVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingQuestion ? '更新' : '创建'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionManagePage;
|
||||
256
src/pages/admin/QuizConfigPage.tsx
Normal file
256
src/pages/admin/QuizConfigPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, InputNumber, Button, Row, Col, Progress, message } from 'antd';
|
||||
import { adminAPI } from '../../services/api';
|
||||
|
||||
interface QuizConfig {
|
||||
singleRatio: number;
|
||||
multipleRatio: number;
|
||||
judgmentRatio: number;
|
||||
textRatio: number;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
const QuizConfigPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminAPI.getQuizConfig();
|
||||
form.setFieldsValue(response.data);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: QuizConfig) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// 验证比例总和
|
||||
const totalRatio = values.singleRatio + values.multipleRatio + values.judgmentRatio + values.textRatio;
|
||||
if (totalRatio !== 100) {
|
||||
message.error('题型比例总和必须为100%');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证总分
|
||||
if (values.totalScore <= 0) {
|
||||
message.error('总分必须大于0');
|
||||
return;
|
||||
}
|
||||
|
||||
await adminAPI.updateQuizConfig(values);
|
||||
message.success('配置更新成功');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '更新配置失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onValuesChange = (changedValues: any, allValues: QuizConfig) => {
|
||||
// 实时更新进度条
|
||||
form.setFieldsValue(allValues);
|
||||
};
|
||||
|
||||
const getProgressColor = (ratio: number) => {
|
||||
if (ratio >= 40) return '#52c41a';
|
||||
if (ratio >= 20) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">抽题配置</h1>
|
||||
<p className="text-gray-600 mt-2">设置各题型的比例和试卷总分</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm" loading={loading}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={onValuesChange}
|
||||
initialValues={{
|
||||
singleRatio: 40,
|
||||
multipleRatio: 30,
|
||||
judgmentRatio: 20,
|
||||
textRatio: 10,
|
||||
totalScore: 100
|
||||
}}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="单选题比例 (%)"
|
||||
name="singleRatio"
|
||||
rules={[{ required: true, message: '请输入单选题比例' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => `${value}%`}
|
||||
parser={(value) => value!.replace('%', '') as any}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={form.getFieldValue('singleRatio') || 0}
|
||||
strokeColor={getProgressColor(form.getFieldValue('singleRatio') || 0)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="多选题比例 (%)"
|
||||
name="multipleRatio"
|
||||
rules={[{ required: true, message: '请输入多选题比例' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => `${value}%`}
|
||||
parser={(value) => value!.replace('%', '') as any}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={form.getFieldValue('multipleRatio') || 0}
|
||||
strokeColor={getProgressColor(form.getFieldValue('multipleRatio') || 0)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="判断题比例 (%)"
|
||||
name="judgmentRatio"
|
||||
rules={[{ required: true, message: '请输入判断题比例' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => `${value}%`}
|
||||
parser={(value) => value!.replace('%', '') as any}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={form.getFieldValue('judgmentRatio') || 0}
|
||||
strokeColor={getProgressColor(form.getFieldValue('judgmentRatio') || 0)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="文字题比例 (%)"
|
||||
name="textRatio"
|
||||
rules={[{ required: true, message: '请输入文字题比例' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => `${value}%`}
|
||||
parser={(value) => value!.replace('%', '') as any}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={form.getFieldValue('textRatio') || 0}
|
||||
strokeColor={getProgressColor(form.getFieldValue('textRatio') || 0)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="试卷总分"
|
||||
name="totalScore"
|
||||
rules={[{ required: true, message: '请输入试卷总分' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={200}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => `${value}分`}
|
||||
parser={(value) => value!.replace('分', '') as any}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div className="h-8 flex items-center text-gray-600">
|
||||
建议设置:100-150分
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<div className="text-sm text-gray-600 mb-4">
|
||||
比例总和:
|
||||
<span className="font-semibold text-blue-600">
|
||||
{(form.getFieldValue('singleRatio') || 0) +
|
||||
(form.getFieldValue('multipleRatio') || 0) +
|
||||
(form.getFieldValue('judgmentRatio') || 0) +
|
||||
(form.getFieldValue('textRatio') || 0)}%
|
||||
</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="rounded-lg"
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 配置说明 */}
|
||||
<Card title="配置说明" className="mt-6 shadow-sm">
|
||||
<div className="space-y-3 text-gray-600">
|
||||
<p>• 各题型比例总和必须为100%</p>
|
||||
<p>• 系统会根据比例和总分自动计算各题型的题目数量</p>
|
||||
<p>• 建议单选题比例不低于30%,确保试卷的覆盖面</p>
|
||||
<p>• 文字题比例建议不超过20%,避免评分主观性过强</p>
|
||||
<p>• 总分设置建议为100分,便于成绩统计和分析</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizConfigPage;
|
||||
236
src/pages/admin/RecordDetailPage.tsx
Normal file
236
src/pages/admin/RecordDetailPage.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Answer {
|
||||
id: string;
|
||||
questionId: string;
|
||||
questionContent: string;
|
||||
questionType: string;
|
||||
userAnswer: string | string[];
|
||||
correctAnswer: string | string[];
|
||||
score: number;
|
||||
questionScore: number;
|
||||
isCorrect: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Record {
|
||||
id: string;
|
||||
userId: string;
|
||||
totalScore: number;
|
||||
correctCount: number;
|
||||
totalCount: number;
|
||||
createdAt: string;
|
||||
answers: Answer[];
|
||||
}
|
||||
|
||||
const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
const [record, setRecord] = useState<Record | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRecordDetail = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/admin/quiz/records/detail/${recordId}`);
|
||||
setRecord(res.data);
|
||||
} catch (error) {
|
||||
message.error('获取答题记录详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecordDetail();
|
||||
}, [recordId]);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const typeStats = record.answers.reduce((acc: any, answer) => {
|
||||
const type = answer.questionType;
|
||||
if (!acc[type]) {
|
||||
acc[type] = { type, total: 0, correct: 0 };
|
||||
}
|
||||
acc[type].total += 1;
|
||||
if (answer.isCorrect) {
|
||||
acc[type].correct += 1;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const typeChartData = Object.values(typeStats).map((item: any) => ({
|
||||
type: item.type,
|
||||
正确率: item.total > 0 ? ((item.correct / item.total) * 100).toFixed(1) : '0.0',
|
||||
}));
|
||||
|
||||
const pieData = [
|
||||
{ name: '正确', value: record.correctCount, color: '#10b981' },
|
||||
{ name: '错误', value: record.totalCount - record.correctCount, color: '#ef4444' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '题目内容',
|
||||
dataIndex: 'questionContent',
|
||||
key: 'questionContent',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'questionType',
|
||||
key: 'questionType',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: '用户答案',
|
||||
dataIndex: 'userAnswer',
|
||||
key: 'userAnswer',
|
||||
width: '20%',
|
||||
render: (answer: string | string[]) => {
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ');
|
||||
}
|
||||
return answer;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '正确答案',
|
||||
dataIndex: 'correctAnswer',
|
||||
key: 'correctAnswer',
|
||||
width: '20%',
|
||||
render: (answer: string | string[]) => {
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ');
|
||||
}
|
||||
return answer;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: '5%',
|
||||
render: (score: number, record: Answer) => (
|
||||
<span className={record.isCorrect ? 'text-green-600' : 'text-red-600'}>
|
||||
{score} / {record.questionScore}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'isCorrect',
|
||||
key: 'isCorrect',
|
||||
width: '5%',
|
||||
render: (isCorrect: boolean) => (
|
||||
<span className={isCorrect ? 'text-green-600' : 'text-red-600'}>
|
||||
{isCorrect ? '✓' : '✗'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">答题记录详情</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
答题时间:{dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} className="mb-6">
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="总得分" value={record.totalScore} suffix="分" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="正确题数" value={record.correctCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="总题数" value={record.totalCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="正确率"
|
||||
value={record.totalCount > 0 ? ((record.correctCount / record.totalCount) * 100).toFixed(1) : 0}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} className="mb-6">
|
||||
<Col span={12}>
|
||||
<Card title="题型正确率">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={typeChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="type" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="正确率" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="答题结果分布">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={120}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex justify-center mt-4">
|
||||
{pieData.map((item) => (
|
||||
<div key={item.name} className="flex items-center mx-4">
|
||||
<div
|
||||
className="w-4 h-4 rounded mr-2"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span>{item.name}: {item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="答题详情">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={record.answers}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordDetailPage;
|
||||
436
src/pages/admin/StatisticsPage.tsx
Normal file
436
src/pages/admin/StatisticsPage.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
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 { formatDateTime } from '../../utils/validation';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { TabPane } = Tabs;
|
||||
const { Option } = Select;
|
||||
|
||||
interface Statistics {
|
||||
totalUsers: number;
|
||||
totalRecords: number;
|
||||
averageScore: number;
|
||||
typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>;
|
||||
}
|
||||
|
||||
interface QuizRecord {
|
||||
id: string;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
subjectName?: string;
|
||||
taskName?: string;
|
||||
totalScore: number;
|
||||
correctCount: number;
|
||||
totalCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserStats {
|
||||
userId: string;
|
||||
userName: string;
|
||||
totalRecords: number;
|
||||
averageScore: number;
|
||||
highestScore: number;
|
||||
lowestScore: number;
|
||||
}
|
||||
|
||||
interface SubjectStats {
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
totalRecords: number;
|
||||
averageScore: number;
|
||||
averageCorrectRate: number;
|
||||
}
|
||||
|
||||
interface TaskStats {
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
totalRecords: number;
|
||||
averageScore: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
|
||||
|
||||
const StatisticsPage = () => {
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
const [records, setRecords] = useState<QuizRecord[]>([]);
|
||||
const [userStats, setUserStats] = useState<UserStats[]>([]);
|
||||
const [subjectStats, setSubjectStats] = useState<SubjectStats[]>([]);
|
||||
const [taskStats, setTaskStats] = useState<TaskStats[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dateRange, setDateRange] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
||||
const [selectedTask, setSelectedTask] = useState<string>('');
|
||||
const [subjects, setSubjects] = useState<any[]>([]);
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
}, []);
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
await Promise.all([
|
||||
fetchStatistics(),
|
||||
fetchRecords(),
|
||||
fetchUserStats(),
|
||||
fetchSubjectStats(),
|
||||
fetchTaskStats(),
|
||||
fetchSubjects(),
|
||||
fetchTasks(),
|
||||
]);
|
||||
};
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminAPI.getStatistics();
|
||||
setStatistics(response.data);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取统计数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取答题记录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户统计失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取科目统计失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务统计失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSubjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/exam-subjects');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSubjects(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取科目列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/exam-tasks');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTasks(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (dates: any) => {
|
||||
setDateRange(dates);
|
||||
// 这里可以添加根据日期范围筛选数据的逻辑
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
const csvContent = [
|
||||
['姓名', '手机号', '科目', '任务', '得分', '正确数', '总题数', '答题时间'],
|
||||
...records.map(record => [
|
||||
record.userName,
|
||||
record.userPhone,
|
||||
record.subjectName || '',
|
||||
record.taskName || '',
|
||||
record.totalScore,
|
||||
record.correctCount,
|
||||
record.totalCount,
|
||||
formatDateTime(record.createdAt)
|
||||
])
|
||||
].map(row => row.join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `答题记录_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('数据导出成功');
|
||||
};
|
||||
|
||||
// 准备图表数据
|
||||
const typeChartData = statistics?.typeStats.map(stat => ({
|
||||
name: stat.type === 'single' ? '单选题' :
|
||||
stat.type === 'multiple' ? '多选题' :
|
||||
stat.type === 'judgment' ? '判断题' : '文字题',
|
||||
正确率: stat.correctRate,
|
||||
总题数: stat.total,
|
||||
})) || [];
|
||||
|
||||
const scoreDistribution = [
|
||||
{ range: '0-59分', count: records.filter(r => r.totalScore < 60).length },
|
||||
{ range: '60-69分', count: records.filter(r => r.totalScore >= 60 && r.totalScore < 70).length },
|
||||
{ range: '70-79分', count: records.filter(r => r.totalScore >= 70 && r.totalScore < 80).length },
|
||||
{ range: '80-89分', count: records.filter(r => r.totalScore >= 80 && r.totalScore < 90).length },
|
||||
{ range: '90-100分', count: records.filter(r => r.totalScore >= 90).length },
|
||||
].filter(item => item.count > 0);
|
||||
|
||||
const overviewColumns = [
|
||||
{ title: '姓名', dataIndex: 'userName', key: 'userName' },
|
||||
{ title: '手机号', dataIndex: 'userPhone', key: 'userPhone' },
|
||||
{ title: '科目', dataIndex: 'subjectName', key: 'subjectName' },
|
||||
{ title: '任务', dataIndex: 'taskName', key: 'taskName' },
|
||||
{ title: '得分', dataIndex: 'totalScore', key: 'totalScore', render: (score: number) => <span className="font-semibold text-blue-600">{score} 分</span> },
|
||||
{ title: '正确率', key: 'correctRate', render: (record: QuizRecord) => {
|
||||
const rate = ((record.correctCount / record.totalCount) * 100).toFixed(1);
|
||||
return <span>{rate}%</span>;
|
||||
}},
|
||||
{ title: '答题时间', dataIndex: 'createdAt', key: 'createdAt', render: (date: string) => formatDateTime(date) },
|
||||
];
|
||||
|
||||
const userStatsColumns = [
|
||||
{ title: '用户姓名', dataIndex: 'userName', key: 'userName' },
|
||||
{ title: '答题次数', dataIndex: 'totalRecords', key: 'totalRecords' },
|
||||
{ title: '平均分', dataIndex: 'averageScore', key: 'averageScore', render: (score: number) => `${score.toFixed(1)} 分` },
|
||||
{ title: '最高分', dataIndex: 'highestScore', key: 'highestScore', render: (score: number) => `${score} 分` },
|
||||
{ title: '最低分', dataIndex: 'lowestScore', key: 'lowestScore', render: (score: number) => `${score} 分` },
|
||||
];
|
||||
|
||||
const subjectStatsColumns = [
|
||||
{ title: '科目名称', dataIndex: 'subjectName', key: 'subjectName' },
|
||||
{ title: '答题次数', dataIndex: 'totalRecords', key: 'totalRecords' },
|
||||
{ title: '平均分', dataIndex: 'averageScore', key: 'averageScore', render: (score: number) => `${score.toFixed(1)} 分` },
|
||||
{ title: '平均正确率', dataIndex: 'averageCorrectRate', key: 'averageCorrectRate', render: (rate: number) => `${rate.toFixed(1)}%` },
|
||||
];
|
||||
|
||||
const taskStatsColumns = [
|
||||
{ title: '任务名称', dataIndex: 'taskName', key: 'taskName' },
|
||||
{ title: '答题次数', dataIndex: 'totalRecords', key: 'totalRecords' },
|
||||
{ title: '平均分', dataIndex: 'averageScore', key: 'averageScore', render: (score: number) => `${score.toFixed(1)} 分` },
|
||||
{ title: '完成率', dataIndex: 'completionRate', key: 'completionRate', render: (rate: number) => `${rate.toFixed(1)}%` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">数据统计分析</h1>
|
||||
<div className="space-x-4">
|
||||
<RangePicker onChange={handleDateRangeChange} />
|
||||
<Button type="primary" onClick={exportData}>
|
||||
导出数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 概览统计 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={statistics?.totalUsers || 0}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总答题数"
|
||||
value={statistics?.totalRecords || 0}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="平均分"
|
||||
value={statistics?.averageScore || 0}
|
||||
precision={1}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
suffix="分"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="活跃率"
|
||||
value={statistics?.totalUsers ? ((statistics.totalRecords / statistics.totalUsers) * 100).toFixed(1) : 0}
|
||||
precision={1}
|
||||
valueStyle={{ color: '#f5222d' }}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<TabPane tab="总体概览" key="overview">
|
||||
{/* 图表 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={12}>
|
||||
<Card title="题型正确率对比" className="shadow-sm">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={typeChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => [`${value}%`, '正确率']} />
|
||||
<Bar dataKey="正确率" fill="#1890ff" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="分数分布" className="shadow-sm">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={scoreDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }: any) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
>
|
||||
{scoreDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 详细记录 */}
|
||||
<Card title="答题记录明细" className="shadow-sm">
|
||||
<Table
|
||||
columns={overviewColumns}
|
||||
dataSource={records}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="用户统计" key="users">
|
||||
<Card title="用户答题统计" className="shadow-sm">
|
||||
<Table
|
||||
columns={userStatsColumns}
|
||||
dataSource={userStats}
|
||||
rowKey="userId"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="科目统计" key="subjects">
|
||||
<Card title="科目答题统计" className="shadow-sm">
|
||||
<Table
|
||||
columns={subjectStatsColumns}
|
||||
dataSource={subjectStats}
|
||||
rowKey="subjectId"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="任务统计" key="tasks">
|
||||
<Card title="考试任务统计" className="shadow-sm">
|
||||
<Table
|
||||
columns={taskStatsColumns}
|
||||
dataSource={taskStats}
|
||||
rowKey="taskId"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatisticsPage;
|
||||
298
src/pages/admin/UserManagePage.tsx
Normal file
298
src/pages/admin/UserManagePage.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
import type { UploadProps } from 'antd';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
password: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const UserManagePage = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||
const [form] = Form.useForm();
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchUsers = async (page = 1, pageSize = 10) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/admin/users?page=${page}&limit=${pageSize}`);
|
||||
setUsers(res.data);
|
||||
setPagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total: res.pagination?.total || res.data.length,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error('获取用户列表失败');
|
||||
console.error('获取用户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleTableChange = (newPagination: any) => {
|
||||
fetchUsers(newPagination.current, newPagination.pageSize);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
form.setFieldsValue({
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
password: user.password,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete('/admin/users', { data: { userId: id } });
|
||||
message.success('删除成功');
|
||||
fetchUsers(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除用户失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingUser) {
|
||||
// 编辑用户
|
||||
await api.put(`/admin/users/${editingUser.id}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
// 新增用户
|
||||
await api.post('/admin/users', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchUsers(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
message.error(editingUser ? '更新失败' : '创建失败');
|
||||
console.error('保存用户失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/users/export');
|
||||
const data = res.data;
|
||||
const XLSX = await import('xlsx');
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '用户列表');
|
||||
XLSX.writeFile(wb, '用户列表.xlsx');
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
message.error('导出失败');
|
||||
console.error('导出用户失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const XLSX = await import('xlsx');
|
||||
const data = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(data);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([JSON.stringify(jsonData)], { type: 'application/json' });
|
||||
formData.append('file', blob, 'users.json');
|
||||
|
||||
const res = await api.post('/admin/users/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
message.success(`导入成功,共导入 ${res.data.imported} 条数据`);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error('导入失败');
|
||||
console.error('导入用户失败:', error);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = (userId: string) => {
|
||||
const newVisible = new Set(visiblePasswords);
|
||||
if (newVisible.has(userId)) {
|
||||
newVisible.delete(userId);
|
||||
} else {
|
||||
newVisible.add(userId);
|
||||
}
|
||||
setVisiblePasswords(newVisible);
|
||||
};
|
||||
|
||||
const handleViewRecords = (userId: string) => {
|
||||
// 打开新窗口查看用户答题记录
|
||||
window.open(`/admin/users/${userId}/records`, '_blank');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '密码',
|
||||
dataIndex: 'password',
|
||||
key: 'password',
|
||||
render: (password: string, record: User) => (
|
||||
<Space>
|
||||
<span>
|
||||
{visiblePasswords.has(record.id) ? password : '••••••'}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
icon={visiblePasswords.has(record.id) ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => togglePasswordVisibility(record.id)}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: User) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => handleViewRecords(record.id)}
|
||||
>
|
||||
答题记录
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除该用户吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
accept: '.xlsx,.xls',
|
||||
showUploadList: false,
|
||||
beforeUpload: handleImport,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">用户管理</h1>
|
||||
<Space>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExport}>
|
||||
导出
|
||||
</Button>
|
||||
<Upload {...uploadProps}>
|
||||
<Button icon={<ImportOutlined />}>
|
||||
导入
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增用户
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '新增用户'}
|
||||
open={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="姓名"
|
||||
rules={[{ required: true, message: '请输入姓名' }]}
|
||||
>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[{ required: true, message: '请输入手机号' }]}
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagePage;
|
||||
168
src/pages/admin/UserRecordsPage.tsx
Normal file
168
src/pages/admin/UserRecordsPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Record {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
totalScore: number;
|
||||
correctCount: number;
|
||||
totalCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const UserRecordsPage = ({ userId }: { userId: string }) => {
|
||||
const [records, setRecords] = useState<Record[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchRecords = async (page = 1, pageSize = 10) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/admin/users/${userId}/records?page=${page}&limit=${pageSize}`);
|
||||
setRecords(res.data.data);
|
||||
setPagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total: res.data.pagination.total,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error('获取答题记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords();
|
||||
}, [userId]);
|
||||
|
||||
const handleTableChange = (newPagination: any) => {
|
||||
fetchRecords(newPagination.current, newPagination.pageSize);
|
||||
};
|
||||
|
||||
const handleViewDetail = (recordId: string) => {
|
||||
window.open(`/admin/quiz/records/detail/${recordId}`, '_blank');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '答题时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '总得分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
render: (score: number) => <span className="font-semibold text-blue-600">{score} 分</span>,
|
||||
},
|
||||
{
|
||||
title: '正确题数',
|
||||
dataIndex: 'correctCount',
|
||||
key: 'correctCount',
|
||||
render: (count: number, record: Record) => `${count} / ${record.totalCount}`,
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
key: 'correctRate',
|
||||
render: (_: any, record: Record) => {
|
||||
const rate = record.totalCount > 0 ? ((record.correctCount / record.totalCount) * 100).toFixed(1) : '0.0';
|
||||
return <span>{rate}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Record) => (
|
||||
<Button type="link" onClick={() => handleViewDetail(record.id)}>
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const scoreDistribution = records.reduce((acc: any, record) => {
|
||||
const range = Math.floor(record.totalScore / 10) * 10;
|
||||
const key = `${range}-${range + 9}`;
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const chartData = Object.entries(scoreDistribution).map(([range, count]) => ({
|
||||
range,
|
||||
count,
|
||||
}));
|
||||
|
||||
const totalRecords = records.length;
|
||||
const averageScore = totalRecords > 0 ? records.reduce((sum, r) => sum + r.totalScore, 0) / totalRecords : 0;
|
||||
const highestScore = totalRecords > 0 ? Math.max(...records.map(r => r.totalScore)) : 0;
|
||||
const lowestScore = totalRecords > 0 ? Math.min(...records.map(r => r.totalScore)) : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">用户答题记录</h1>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} className="mb-6">
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="总答题次数" value={totalRecords} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="平均得分" value={averageScore.toFixed(1)} suffix="分" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="最高得分" value={highestScore} suffix="分" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="最低得分" value={lowestScore} suffix="分" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
<Card title="得分分布" className="mb-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="range" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="答题记录">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserRecordsPage;
|
||||
115
src/services/api.ts
Normal file
115
src/services/api.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config: any) => {
|
||||
// 添加管理员token
|
||||
if (typeof window !== 'undefined') {
|
||||
const adminToken = localStorage.getItem('survey_admin');
|
||||
if (adminToken) {
|
||||
const { token } = JSON.parse(adminToken);
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: any) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response: any) => {
|
||||
// 如果响应类型是 blob,直接返回原始响应
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const { data } = response;
|
||||
if (data.success) {
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(data.message || '请求失败');
|
||||
}
|
||||
},
|
||||
(error: any) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 未授权,清除管理员信息
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('survey_admin');
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 用户相关API
|
||||
export const userAPI = {
|
||||
createUser: (data: { name: string; phone: string; password?: string }) => api.post('/users', data),
|
||||
getUser: (id: string) => api.get(`/users/${id}`),
|
||||
validateUserInfo: (data: { name: string; phone: string }) => api.post('/users/validate', data),
|
||||
};
|
||||
|
||||
// 题目相关API
|
||||
export const questionAPI = {
|
||||
getQuestions: (params?: { type?: string; page?: number; limit?: number }) =>
|
||||
api.get('/questions', { params }),
|
||||
getQuestion: (id: string) => api.get(`/questions/${id}`),
|
||||
createQuestion: (data: any) => api.post('/questions', data),
|
||||
updateQuestion: (id: string, data: any) => api.put(`/questions/${id}`, data),
|
||||
deleteQuestion: (id: string) => api.delete(`/questions/${id}`),
|
||||
importQuestions: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post('/questions/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
exportQuestions: (params?: { type?: string; category?: string }) =>
|
||||
api.get('/questions/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// 答题相关API
|
||||
export const quizAPI = {
|
||||
generateQuiz: (userId: string, subjectId?: string, taskId?: string) =>
|
||||
api.post('/quiz/generate', { userId, subjectId, taskId }),
|
||||
submitQuiz: (data: { userId: string; subjectId?: string; taskId?: string; answers: any[] }) =>
|
||||
api.post('/quiz/submit', data),
|
||||
getUserRecords: (userId: string, params?: { page?: number; limit?: number }) =>
|
||||
api.get(`/quiz/records/${userId}`, { params }),
|
||||
getRecordDetail: (recordId: string) => api.get(`/quiz/records/detail/${recordId}`),
|
||||
getAllRecords: (params?: { page?: number; limit?: number }) =>
|
||||
api.get('/quiz/records', { params }),
|
||||
};
|
||||
|
||||
// 管理员相关API
|
||||
export const adminAPI = {
|
||||
login: (data: { username: string; password: string }) => api.post('/admin/login', data),
|
||||
getQuizConfig: () => api.get('/admin/config'),
|
||||
updateQuizConfig: (data: any) => api.put('/admin/config', data),
|
||||
getStatistics: () => api.get('/admin/statistics'),
|
||||
updatePassword: (data: { username: string; oldPassword: string; newPassword: string }) =>
|
||||
api.put('/admin/password', data),
|
||||
};
|
||||
|
||||
export default api;
|
||||
20
src/stores/userStore.ts
Normal file
20
src/stores/userStore.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserStore {
|
||||
user: User | null;
|
||||
setUser: (user: User) => void;
|
||||
clearUser: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserStore>((set) => ({
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
clearUser: () => set({ user: null }),
|
||||
}));
|
||||
28
src/utils/request.ts
Normal file
28
src/utils/request.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: any) => {
|
||||
const { data } = response;
|
||||
if (data.success) {
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(data.message || '请求失败');
|
||||
}
|
||||
},
|
||||
(error: any) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export { request };
|
||||
88
src/utils/validation.ts
Normal file
88
src/utils/validation.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// 姓名验证
|
||||
export const validateName = (name: string): { valid: boolean; message: string } => {
|
||||
if (!name || name.trim().length === 0) {
|
||||
return { valid: false, message: '请输入姓名' };
|
||||
}
|
||||
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return { valid: false, message: '姓名长度必须在2-20个字符之间' };
|
||||
}
|
||||
|
||||
// 支持中英文
|
||||
const nameRegex = /^[\u4e00-\u9fa5a-zA-Z\s]+$/;
|
||||
if (!nameRegex.test(name)) {
|
||||
return { valid: false, message: '姓名只能包含中文、英文和空格' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
};
|
||||
|
||||
// 手机号验证
|
||||
export const validatePhone = (phone: string): { valid: boolean; message: string } => {
|
||||
if (!phone || phone.trim().length === 0) {
|
||||
return { valid: false, message: '请输入手机号' };
|
||||
}
|
||||
|
||||
// 中国手机号验证:11位数字,1开头,第二位为3-9
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(phone)) {
|
||||
return { valid: false, message: '请输入正确的中国手机号' };
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
export const validateUserForm = (name: string, phone: string): {
|
||||
valid: boolean;
|
||||
nameError: string;
|
||||
phoneError: string;
|
||||
} => {
|
||||
const nameValidation = validateName(name);
|
||||
const phoneValidation = validatePhone(phone);
|
||||
|
||||
return {
|
||||
valid: nameValidation.valid && phoneValidation.valid,
|
||||
nameError: nameValidation.message,
|
||||
phoneError: phoneValidation.message
|
||||
};
|
||||
};
|
||||
|
||||
// 生成唯一ID
|
||||
export const generateId = (): string => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
export const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// 计算正确率
|
||||
export const calculateCorrectRate = (correct: number, total: number): string => {
|
||||
if (total === 0) return '0%';
|
||||
return ((correct / total) * 100).toFixed(1) + '%';
|
||||
};
|
||||
|
||||
// 题型映射
|
||||
export const questionTypeMap = {
|
||||
single: '单选题',
|
||||
multiple: '多选题',
|
||||
judgment: '判断题',
|
||||
text: '文字描述题'
|
||||
};
|
||||
|
||||
// 题型颜色
|
||||
export const questionTypeColors = {
|
||||
single: 'blue',
|
||||
multiple: 'green',
|
||||
judgment: 'orange',
|
||||
text: 'purple'
|
||||
};
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user