第一版提交,答题功能OK,题库管理待完善

This commit is contained in:
2025-12-18 19:07:21 +08:00
parent e5600535be
commit ba252b2f56
93 changed files with 20431 additions and 1 deletions

75
src/App.tsx Normal file
View 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
View 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

View 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;
};

View 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;
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
export default function Home() {
return <div></div>;
}

177
src/pages/HomePage.tsx Normal file
View 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
View 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
View 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;

View 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
View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />