基本功能完成,下一步开始美化UI

This commit is contained in:
2025-12-19 16:02:38 +08:00
parent 465d4d7b4a
commit 6ac216d184
46 changed files with 2576 additions and 618 deletions

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Typography } from 'antd';
interface LogoProps {
variant?: 'primary' | 'secondary';
className?: string;
theme?: 'light' | 'dark';
}
/**
* Brand Logo Component
*
* Implements the placeholder specification:
* - Primary: 100x40px, for Header
* - Secondary: 80x30px, for Footer
* - Contains "Placeholder" watermark at 15% opacity
*/
export const Logo: React.FC<LogoProps> = ({
variant = 'primary',
className = '',
theme = 'light'
}) => {
const isPrimary = variant === 'primary';
// Dimensions
const width = isPrimary ? 100 : 80;
const height = isPrimary ? 40 : 30;
// Styles
const containerStyle: React.CSSProperties = {
width: `${width}px`,
height: `${height}px`,
backgroundColor: '#f3f4f6', // gray-100
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
border: '1px dashed #d1d5db', // gray-300
borderRadius: '4px',
overflow: 'hidden',
};
const watermarkStyle: React.CSSProperties = {
position: 'absolute',
color: '#000',
opacity: 0.15,
fontSize: isPrimary ? '12px' : '10px',
fontWeight: 'bold',
transform: 'rotate(-15deg)',
whiteSpace: 'nowrap',
userSelect: 'none',
pointerEvents: 'none',
};
const textStyle: React.CSSProperties = {
color: '#008C8C', // Mars Green
fontWeight: 700,
fontSize: isPrimary ? '16px' : '12px',
zIndex: 1,
};
return (
<div
className={`logo-placeholder ${className}`}
style={containerStyle}
title="Logo Placeholder (Microsoft style)"
>
<div style={watermarkStyle}>Placeholder</div>
<span style={textStyle}>
{/* Simulating a logo icon/text structure */}
{isPrimary ? 'LOGO' : 'Logo'}
</span>
</div>
);
};

View File

@@ -2,23 +2,17 @@
@tailwind components;
@tailwind utilities;
/* 自定义样式 */
.ant-card {
border-radius: 8px;
@layer base {
body {
@apply antialiased text-gray-800 bg-gray-50;
font-feature-settings: "cv11", "ss01";
}
}
.ant-btn {
border-radius: 6px;
}
.ant-input, .ant-input-number, .ant-select-selector {
border-radius: 6px;
}
/* 移动端适配 */
/* 移动端适配优化 */
@media (max-width: 768px) {
.ant-card {
margin: 8px;
/* 移除强制 margin交给布局控制 */
}
.ant-form-item-label {
@@ -26,55 +20,68 @@
}
.ant-table {
font-size: 12px;
font-size: 13px; /* 稍微调大一点,提升可读性 */
}
}
/* 自定义滚动条 */
/* 自定义滚动条 - 更加隐形优雅 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
background: #d1d5db;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
background: #9ca3af;
}
/* 动画效果 */
.fade-in {
animation: fadeIn 0.3s ease-in;
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0);
transform: translateY(0) scale(1);
}
}
/* 响应式布局 */
/* 响应式布局容器 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
padding: 0 24px; /* 增加两边留白 */
}
@media (max-width: 768px) {
.container {
padding: 0 8px;
padding: 0 16px;
}
}
}
/* User selection scrollable area */
.user-select-scrollable .ant-select-selector {
max-height: 120px;
overflow-y: auto !important;
}
/* Brand Utilities */
.text-mars {
color: #008C8C;
}
.bg-mars {
background-color: #008C8C;
}

View File

@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import {
DashboardOutlined,
QuestionCircleOutlined,
SettingOutlined,
BarChartOutlined,
UserOutlined,
LogoutOutlined,
@@ -12,11 +11,14 @@ import {
SafetyOutlined,
BookOutlined,
CalendarOutlined,
TeamOutlined
TeamOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons';
import { useAdmin } from '../contexts';
import { Logo } from '../components/common/Logo';
const { Header, Sider, Content } = Layout;
const { Header, Sider, Content, Footer } = Layout;
const AdminLayout = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate();
@@ -89,11 +91,20 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
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>
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme="light"
className="shadow-md z-10"
width={240}
>
<div className="h-16 flex items-center justify-center border-b border-gray-100">
{collapsed ? (
<span className="text-xl font-bold text-mars-500">OA</span>
) : (
<Logo variant="primary" />
)}
</div>
<Menu
mode="inline"
@@ -101,34 +112,49 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}
className="py-4"
/>
</Sider>
<Layout>
<Header className="bg-white shadow-sm flex justify-between items-center px-6">
<Layout className="bg-gray-50/50">
<Header className="bg-white shadow-sm flex justify-between items-center px-6 h-16 sticky top-0 z-10">
<Button
type="text"
icon={collapsed ? <DashboardOutlined /> : <DashboardOutlined />}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
className="text-lg"
className="text-lg w-10 h-10 flex items-center justify-center"
/>
<div className="flex items-center">
<span className="mr-4 text-gray-600">
<div className="flex items-center gap-4">
<span className="text-gray-600 hidden sm:block">
{admin?.username}
</span>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Avatar icon={<UserOutlined />} className="cursor-pointer" />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" arrow>
<Avatar
icon={<UserOutlined />}
className="cursor-pointer bg-mars-100 text-mars-600 hover:bg-mars-200 transition-colors"
/>
</Dropdown>
</div>
</Header>
<Content className="m-6 p-6 bg-white rounded-lg shadow-sm">
{children}
<Content className="m-6 flex flex-col">
<div className="flex-1 bg-white rounded-xl shadow-sm p-6 min-h-[calc(100vh-160px)]">
{children}
</div>
</Content>
<Footer className="bg-transparent text-center py-6 px-8 text-gray-400 text-sm flex flex-col md:flex-row justify-between items-center">
<div>
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<div className="mt-4 md:mt-0">
<Logo variant="secondary" />
</div>
</Footer>
</Layout>
</Layout>
);
};
export default AdminLayout;
export default AdminLayout;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Layout } from 'antd';
import { Logo } from '../components/common/Logo';
const { Header, Content, Footer } = Layout;
export const UserLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<Layout className="min-h-screen bg-gray-50">
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
<Logo variant="primary" />
</Header>
<Content className="flex flex-col">
<div className="flex-1 p-4 md:p-8 bg-gradient-to-br from-mars-50/30 to-white">
{children}
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
<div className="mb-4 md:mb-0">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<Logo variant="secondary" />
</Footer>
</Layout>
);
};

View File

@@ -13,9 +13,38 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<ConfigProvider
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 6,
colorPrimary: '#008C8C',
colorInfo: '#008C8C',
colorLink: '#008C8C',
borderRadius: 8,
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, sans-serif',
},
components: {
Button: {
controlHeight: 40,
controlHeightLG: 48,
borderRadius: 8,
primaryShadow: '0 2px 0 rgba(0, 140, 140, 0.1)',
},
Input: {
controlHeight: 40,
controlHeightLG: 48,
borderRadius: 8,
},
Select: {
controlHeight: 40,
controlHeightLG: 48,
borderRadius: 8,
},
Card: {
borderRadiusLG: 12,
boxShadowTertiary: '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)',
},
Layout: {
colorBgHeader: '#ffffff',
colorBgSider: '#ffffff',
}
}
}}
>
<UserProvider>
@@ -28,4 +57,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>
);
);

View File

@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react';
import { Card, Form, Input, Button, message, Typography, AutoComplete } from 'antd';
import { Card, Form, Input, Button, message, Typography, AutoComplete, Layout } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useUser } from '../contexts';
import { userAPI } from '../services/api';
import { validateUserForm } from '../utils/validation';
import { Logo } from '../components/common/Logo';
const { Header, Content, Footer } = Layout;
const { Title } = Typography;
interface LoginHistory {
@@ -116,102 +118,111 @@ const HomePage = () => {
};
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>
<Layout className="min-h-screen bg-gray-50">
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
<Logo variant="primary" />
</Header>
<Content className="flex items-center justify-center p-4 bg-gradient-to-br from-mars-50 to-white">
<div className="w-full max-w-md">
<Card className="shadow-xl border-t-4 border-t-mars-500">
<div className="text-center mb-8">
<Title level={2} className="text-mars-600 !mb-2">
</Title>
<p className="text-gray-500">
</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: '姓名只能包含中文、英文和空格' }
]}
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
size="large"
>
<AutoComplete
options={historyOptions}
onSelect={handleNameSelect}
onChange={handleNameChange}
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"
<Form.Item
label="姓名"
name="name"
rules={[
{ required: true, message: '请输入姓名' },
{ min: 2, max: 20, message: '姓名长度必须在2-20个字符之间' },
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s]+$/, message: '姓名只能包含中文、英文和空格' }
]}
>
</Button>
</Form.Item>
</Form>
<AutoComplete
options={historyOptions}
onSelect={handleNameSelect}
onChange={handleNameChange}
placeholder="请输入您的姓名"
filterOption={(inputValue, option) =>
option!.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
}
/>
</Form.Item>
<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>
<Form.Item
label="手机号"
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的中国手机号' }
]}
>
<Input
placeholder="请输入11位手机号"
maxLength={11}
/>
</Form.Item>
<Form.Item
label="登录密码"
name="password"
rules={[
{ required: true, message: '请输入登录密码' }
]}
>
<Input.Password
placeholder="请输入登录密码"
autoComplete="new-password"
visibilityToggle
/>
</Form.Item>
<Form.Item className="mb-2 mt-8">
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="bg-mars-500 hover:!bg-mars-600 border-none shadow-md h-12 text-lg font-medium"
>
</Button>
</Form.Item>
</Form>
<div className="mt-6 text-center">
<a
href="/admin/login"
className="text-mars-600 hover:text-mars-800 text-sm transition-colors"
>
</a>
</div>
</Card>
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
<div className="mb-4 md:mb-0">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<Logo variant="secondary" />
</Footer>
</Layout>
);
};
export default HomePage;
export default HomePage;

View File

@@ -4,6 +4,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { useUser, useQuiz } from '../contexts';
import { quizAPI } from '../services/api';
import { questionTypeMap } from '../utils/validation';
import { UserLayout } from '../layouts/UserLayout';
const { TextArea } = Input;
@@ -204,7 +205,7 @@ const QuizPage = () => {
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">
<Radio key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
{String.fromCharCode(65 + index)}. {option}
</Radio>
))}
@@ -219,7 +220,7 @@ const QuizPage = () => {
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">
<Checkbox key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors w-full">
{String.fromCharCode(65 + index)}. {option}
</Checkbox>
))}
@@ -233,10 +234,10 @@ const QuizPage = () => {
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 value="正确" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
<Radio value="错误" className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
<Radio value="错误" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
</Radio.Group>
@@ -249,7 +250,7 @@ const QuizPage = () => {
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"
className="rounded-lg border-gray-300 focus:border-mars-500 focus:ring-mars-500"
/>
);
@@ -260,12 +261,14 @@ const QuizPage = () => {
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>
<UserLayout>
<div className="flex items-center justify-center h-full min-h-[500px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-mars-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
</div>
</UserLayout>
);
}
@@ -273,22 +276,22 @@ const QuizPage = () => {
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">
<UserLayout>
<div className="max-w-4xl mx-auto">
{/* 头部信息 */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<div className="flex justify-between items-center">
<div className="bg-white rounded-xl shadow-sm p-6 mb-6 border border-gray-100">
<div className="flex justify-between items-center mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">线</h1>
<p className="text-gray-600 mt-1">
<p className="text-gray-500 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'
<div className="text-sm text-gray-500"></div>
<div className={`text-2xl font-bold tabular-nums ${
timeLeft < 300 ? 'text-red-600' : 'text-mars-600'
}`}>
{formatTime(timeLeft)}
</div>
@@ -298,31 +301,32 @@ const QuizPage = () => {
<Progress
percent={Math.round(progress)}
strokeColor="#3b82f6"
strokeColor="#008C8C"
trailColor="#f0fcfc"
showInfo={false}
className="mt-4"
className="mt-2"
/>
</div>
{/* 题目卡片 */}
<Card className="shadow-sm">
<div className="mb-6">
<Card className="shadow-sm border border-gray-100 rounded-xl">
<div className="mb-8">
<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">
<span className="text-sm text-gray-500 font-medium">
{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">
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-500 text-xs rounded border border-gray-100">
{currentQuestion.category}
</span>
</div>
)}
<h2 className="text-lg font-medium text-gray-900 leading-relaxed">
<h2 className="text-xl font-medium text-gray-800 leading-relaxed">
{currentQuestion.content}
</h2>
</div>
@@ -332,11 +336,12 @@ const QuizPage = () => {
</div>
{/* 操作按钮 */}
<div className="flex justify-between items-center">
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Button
onClick={handlePrevious}
disabled={currentQuestionIndex === 0}
className="px-6"
size="large"
className="px-6 h-10 hover:border-mars-500 hover:text-mars-500"
>
</Button>
@@ -347,7 +352,8 @@ const QuizPage = () => {
type="primary"
onClick={() => handleSubmit()}
loading={submitting}
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
size="large"
className="px-8 h-10 bg-mars-600 hover:bg-mars-700 border-none shadow-md"
>
</Button>
@@ -355,7 +361,8 @@ const QuizPage = () => {
<Button
type="primary"
onClick={handleNext}
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
size="large"
className="px-8 h-10 bg-mars-500 hover:bg-mars-600 border-none shadow-md"
>
</Button>
@@ -364,8 +371,8 @@ const QuizPage = () => {
</div>
</Card>
</div>
</div>
</UserLayout>
);
};
export default QuizPage;
export default QuizPage;

View File

@@ -4,6 +4,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useUser } from '../contexts';
import { quizAPI } from '../services/api';
import { formatDateTime } from '../utils/validation';
import { UserLayout } from '../layouts/UserLayout';
const { Item } = Descriptions;
@@ -152,25 +153,29 @@ const ResultPage = () => {
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>
<UserLayout>
<div className="flex justify-center items-center h-full min-h-[500px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-mars-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
</div>
</UserLayout>
);
}
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>
<UserLayout>
<div className="flex justify-center items-center h-full min-h-[500px]">
<div className="text-center">
<p className="text-gray-600 mb-4"></p>
<Button type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600">
</Button>
</div>
</div>
</div>
</UserLayout>
);
}
@@ -178,16 +183,16 @@ const ResultPage = () => {
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">
<UserLayout>
<div className="max-w-4xl mx-auto">
{/* 结果概览 */}
<Card className="shadow-lg mb-8">
<Card className="shadow-lg mb-8 rounded-xl border-t-4 border-t-mars-500">
<Result
status={status as any}
title={`答题完成!您的得分是 ${record.totalScore}`}
subTitle={`正确率 ${correctRate}% (${record.correctCount}/${record.totalCount})`}
extra={[
<Button key="back" onClick={handleBackToHome} className="mr-4">
<Button key="back" type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600 border-none px-8 h-10">
</Button>
]}
@@ -195,8 +200,8 @@ const ResultPage = () => {
</Card>
{/* 基本信息 */}
<Card className="shadow-lg mb-8">
<h3 className="text-lg font-semibold mb-4"></h3>
<Card className="shadow-lg mb-8 rounded-xl">
<h3 className="text-lg font-semibold mb-4 text-gray-800 border-l-4 border-mars-500 pl-3"></h3>
<Descriptions bordered column={2}>
<Item label="姓名">{user?.name}</Item>
<Item label="手机号">{user?.phone}</Item>
@@ -208,16 +213,16 @@ const ResultPage = () => {
</Card>
{/* 答案详情 */}
<Card className="shadow-lg">
<h3 className="text-lg font-semibold mb-4"></h3>
<Card className="shadow-lg rounded-xl">
<h3 className="text-lg font-semibold mb-4 text-gray-800 border-l-4 border-mars-500 pl-3"></h3>
<div className="space-y-4">
{answers.map((answer, index) => (
<div
key={answer.id}
className={`p-4 rounded-lg border-2 ${
className={`p-4 rounded-lg border ${
answer.isCorrect
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
? 'border-green-200 bg-green-50/50'
: 'border-red-200 bg-red-50/50'
}`}
>
<div className="flex justify-between items-start mb-2">
@@ -243,7 +248,7 @@ const ResultPage = () => {
</div>
<div className="mb-2">
<span className="text-gray-600"></span>
<span className="text-gray-800">{answer.questionContent || '题目内容加载失败'}</span>
<span className="text-gray-800 font-medium">{answer.questionContent || '题目内容加载失败'}</span>
</div>
<div className="mb-2">
<span className="text-gray-600"></span>
@@ -255,10 +260,9 @@ const ResultPage = () => {
{renderCorrectAnswer(answer)}
</div>
)}
<div className="mb-2">
<span className="text-gray-600"></span>
<span className="text-gray-800">
{answer.questionScore || 0} {answer.score}
<div className="mb-2 pt-2 border-t border-gray-100 mt-2">
<span className="text-gray-500 text-sm">
{answer.questionScore || 0} <span className="font-medium text-gray-800">{answer.score}</span>
</span>
</div>
</div>
@@ -266,8 +270,8 @@ const ResultPage = () => {
</div>
</Card>
</div>
</div>
</UserLayout>
);
};
export default ResultPage;
export default ResultPage;

View File

@@ -4,6 +4,7 @@ import { ClockCircleOutlined, BookOutlined, UserOutlined } from '@ant-design/ico
import { useNavigate, useLocation } from 'react-router-dom';
import { request } from '../utils/request';
import { useUserStore } from '../stores/userStore';
import { UserLayout } from '../layouts/UserLayout';
const { Title, Text } = Typography;
@@ -123,168 +124,170 @@ export const SubjectSelectionPage: React.FC = () => {
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<Spin size="large" />
</div>
<UserLayout>
<div className="flex justify-center items-center h-full min-h-[500px]">
<Spin size="large" />
</div>
</UserLayout>
);
}
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>
<UserLayout>
<div className="container mx-auto max-w-6xl">
<div className="mb-8 text-center">
<Title level={2} className="!text-mars-600 mb-2">
</Title>
<Text type="secondary" className="block text-lg">
</Text>
</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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 考试科目选择 */}
<div>
<div className="flex items-center mb-6 border-b border-gray-200 pb-2">
<BookOutlined className="text-2xl mr-3 text-mars-600" />
<Title level={3} className="!mb-0 !text-gray-700"></Title>
</div>
<div className="space-y-4">
{subjects.map((subject) => (
<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'
key={subject.id}
className={`cursor-pointer transition-all duration-300 border-l-4 ${
selectedSubject === subject.id
? 'border-l-mars-500 border-t border-r border-b border-mars-200 shadow-md bg-mars-50'
: 'border-l-transparent hover:border-l-mars-300 hover:shadow-md'
}`}
onClick={() => {
setSelectedTask(task.id);
setSelectedSubject('');
setSelectedSubject(subject.id);
setSelectedTask('');
}}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<Title level={4} className="mb-2">{task.name}</Title>
<Title level={4} className={`mb-2 ${selectedSubject === subject.id ? 'text-mars-700' : 'text-gray-800'}`}>
{subject.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>
<ClockCircleOutlined className="mr-2 text-gray-400" />
<Text className="text-gray-600">{subject.timeLimitMinutes}</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>
<span className="mr-2 text-gray-400">:</span>
<Text strong className="text-gray-700">{subject.totalScore}</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 className="text-sm text-gray-500 bg-gray-50 p-2 rounded">
<div className="mb-1 font-medium">:</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>
{selectedSubject === subject.id && (
<div className="text-mars-600">
<div className="w-8 h-8 bg-mars-500 rounded-full flex items-center justify-center shadow-sm">
<span className="text-white text-lg font-bold"></span>
</div>
</div>
)}
</div>
</Card>
);
})}
))}
</div>
</div>
{tasks.length === 0 && (
<Card className="text-center py-8">
<Text type="secondary"></Text>
</Card>
)}
{/* 考试任务选择 */}
<div>
<div className="flex items-center mb-6 border-b border-gray-200 pb-2">
<UserOutlined className="text-2xl mr-3 text-mars-400" />
<Title level={3} className="!mb-0 !text-gray-700"></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-300 border-l-4 ${
selectedTask === task.id
? 'border-l-mars-400 border-t border-r border-b border-mars-200 shadow-md bg-mars-50'
: 'border-l-transparent hover:border-l-mars-300 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 ${selectedTask === task.id ? 'text-mars-700' : 'text-gray-800'}`}>
{task.name}
</Title>
<Space direction="vertical" size="small" className="mb-3">
<div className="flex items-center">
<BookOutlined className="mr-2 text-gray-400" />
<Text className="text-gray-600">{subject?.name || '未知科目'}</Text>
</div>
<div className="flex items-center">
<ClockCircleOutlined className="mr-2 text-gray-400" />
<Text className="text-gray-600">
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
</Text>
</div>
{subject && (
<div className="flex items-center">
<span className="mr-2 text-gray-400">:</span>
<Text className="text-gray-600">{subject.timeLimitMinutes}</Text>
</div>
)}
</Space>
</div>
{selectedTask === task.id && (
<div className="text-mars-400">
<div className="w-8 h-8 bg-mars-400 rounded-full flex items-center justify-center shadow-sm">
<span className="text-white text-lg font-bold"></span>
</div>
</div>
)}
</div>
</Card>
);
})}
</div>
{tasks.length === 0 && (
<Card className="text-center py-12 bg-gray-50 border-dashed border-2 border-gray-200">
<Text type="secondary" className="text-lg"></Text>
</Card>
)}
</div>
</div>
<div className="mt-12 text-center space-x-6">
<Button
type="primary"
size="large"
className="px-12 h-14 text-lg font-medium shadow-lg hover:scale-105 transition-transform bg-mars-500 hover:bg-mars-600 border-none"
onClick={startQuiz}
disabled={!selectedSubject && !selectedTask}
>
</Button>
<Button
type="default"
size="large"
className="px-8 h-14 text-lg hover:border-mars-500 hover:text-mars-500"
onClick={() => navigate('/tasks')}
icon={<UserOutlined />}
>
</Button>
</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>
</UserLayout>
);
};
};

View File

@@ -4,6 +4,7 @@ import { ClockCircleOutlined, BookOutlined, CalendarOutlined, CheckCircleOutline
import { useNavigate } from 'react-router-dom';
import { request } from '../utils/request';
import { useUserStore } from '../stores/userStore';
import { UserLayout } from '../layouts/UserLayout';
const { Title, Text } = Typography;
@@ -73,7 +74,7 @@ export const UserTaskPage: React.FC = () => {
if (now < startAt) return 'blue';
if (now > endAt) return 'red';
return 'green';
return 'cyan'; // Using cyan to match Mars Green family better than pure green
};
const getStatusText = (task: ExamTask) => {
@@ -99,7 +100,7 @@ export const UserTaskPage: React.FC = () => {
key: 'subjectName',
render: (text: string) => (
<Space>
<BookOutlined className="text-blue-600" />
<BookOutlined className="text-mars-600" />
<Text>{text}</Text>
</Space>
)
@@ -116,7 +117,7 @@ export const UserTaskPage: React.FC = () => {
key: 'timeLimitMinutes',
render: (minutes: number) => (
<Space>
<ClockCircleOutlined className="text-gray-600" />
<ClockCircleOutlined className="text-gray-500" />
<Text>{minutes}</Text>
</Space>
)
@@ -127,13 +128,13 @@ export const UserTaskPage: React.FC = () => {
render: (record: ExamTask) => (
<Space direction="vertical" size={0}>
<Space>
<CalendarOutlined className="text-gray-600" />
<CalendarOutlined className="text-gray-500" />
<Text type="secondary" style={{ fontSize: '12px' }}>
{new Date(record.startAt).toLocaleDateString()}
</Text>
</Space>
<Space>
<CalendarOutlined className="text-gray-600" />
<CalendarOutlined className="text-gray-500" />
<Text type="secondary" style={{ fontSize: '12px' }}>
{new Date(record.endAt).toLocaleDateString()}
</Text>
@@ -145,7 +146,7 @@ export const UserTaskPage: React.FC = () => {
title: '状态',
key: 'status',
render: (record: ExamTask) => (
<Tag color={getStatusColor(record)}>
<Tag color={getStatusColor(record)} className="rounded-full px-3">
{getStatusText(record)}
</Tag>
)
@@ -167,6 +168,7 @@ export const UserTaskPage: React.FC = () => {
onClick={() => startTask(record)}
disabled={!canStart}
icon={<CheckCircleOutlined />}
className={canStart ? "bg-mars-500 hover:bg-mars-600 border-none" : ""}
>
{canStart ? '开始考试' : '不可用'}
</Button>
@@ -178,48 +180,54 @@ export const UserTaskPage: React.FC = () => {
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<Spin size="large" />
</div>
<UserLayout>
<div className="flex justify-center items-center h-full min-h-[500px]">
<Spin size="large" />
</div>
</UserLayout>
);
}
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>
<UserLayout>
<div className="container mx-auto max-w-6xl">
<div className="mb-8 text-center">
<Title level={2} className="!text-mars-600 mb-2">
</Title>
<Text type="secondary" className="block text-lg">
</Text>
</div>
<Card className="shadow-sm">
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`
}}
locale={{
emptyText: '暂无考试任务'
}}
/>
</Card>
<Card className="shadow-md border-t-4 border-t-mars-500 rounded-xl">
<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 className="mt-8 text-center">
<Button
type="default"
size="large"
onClick={() => navigate('/subjects')}
icon={<BookOutlined />}
className="px-8 h-12 hover:border-mars-500 hover:text-mars-500"
>
</Button>
</div>
</div>
</div>
</UserLayout>
);
};
};

View File

@@ -8,7 +8,7 @@ import {
} from '@ant-design/icons';
import { adminAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend } from 'recharts';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend, Label } from 'recharts';
interface Statistics {
totalUsers: number;
@@ -98,7 +98,7 @@ const AdminDashboardPage = () => {
title: '得分',
dataIndex: 'totalScore',
key: 'totalScore',
render: (score: number) => <span className="font-semibold text-blue-600">{score} </span>,
render: (score: number) => <span className="font-semibold text-mars-600">{score} </span>,
},
{
title: '正确率',
@@ -139,6 +139,7 @@ const AdminDashboardPage = () => {
icon={<ReloadOutlined />}
onClick={fetchDashboardData}
loading={loading}
className="bg-mars-500 hover:bg-mars-600"
>
</Button>
@@ -147,33 +148,33 @@ const AdminDashboardPage = () => {
{/* 统计卡片 */}
<Row gutter={16} className="mb-8">
<Col span={8}>
<Card className="shadow-sm">
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="总用户数"
value={statistics?.totalUsers || 0}
prefix={<UserOutlined className="text-blue-500" />}
valueStyle={{ color: '#1890ff' }}
prefix={<UserOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm">
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="答题记录"
value={statistics?.totalRecords || 0}
prefix={<BarChartOutlined className="text-green-500" />}
valueStyle={{ color: '#52c41a' }}
prefix={<BarChartOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm">
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="平均得分"
value={statistics?.averageScore || 0}
precision={1}
prefix={<QuestionCircleOutlined className="text-orange-500" />}
valueStyle={{ color: '#fa8c16' }}
prefix={<QuestionCircleOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
suffix="分"
/>
</Card>
@@ -186,14 +187,14 @@ const AdminDashboardPage = () => {
<Row gutter={16}>
{statistics.typeStats.map((stat) => (
<Col span={6} key={stat.type}>
<Card size="small" className="text-center">
<Card size="small" className="text-center hover:shadow-sm transition-shadow">
<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">
<div className="text-2xl font-bold text-mars-600">
{stat.correctRate}%
</div>
<div className="text-xs text-gray-500">
@@ -244,13 +245,13 @@ const AdminDashboardPage = () => {
return (
<div className="flex items-center">
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2">
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-300"
className="h-full bg-mars-500 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<span className="font-semibold text-blue-600">{progress}%</span>
<span className="font-semibold text-mars-600">{progress}%</span>
</div>
);
},
@@ -262,41 +263,66 @@ const AdminDashboardPage = () => {
// 计算各类人数
const total = record.totalUsers;
const completed = record.completedUsers;
const passed = Math.round(completed * (record.passRate / 100));
const excellent = Math.round(completed * (record.excellentRate / 100));
const incomplete = total - completed;
// 准备饼图数据
// 原始计算
const passedTotal = Math.round(completed * (record.passRate / 100));
const excellentTotal = Math.round(completed * (record.excellentRate / 100));
// 互斥分类计算
const incomplete = total - completed;
const failed = completed - passedTotal;
const passedOnly = passedTotal - excellentTotal;
const excellent = excellentTotal;
// 准备环形图数据 (互斥分类)
const pieData = [
{ name: '已完成', value: completed, color: '#1890ff' },
{ name: '合格', value: passed, color: '#52c41a' },
{ name: '优秀', value: excellent, color: '#fa8c16' },
{ name: '未完成', value: incomplete, color: '#d9d9d9' }
{ name: '优秀', value: excellent, color: '#008C8C' }, // Mars Green (Primary)
{ name: '合格', value: passedOnly, color: '#00A3A3' }, // Mars Light
{ name: '不及格', value: failed, color: '#ff4d4f' }, // Red (Error)
{ name: '未完成', value: incomplete, color: '#f0f0f0' } // Gray
];
// 只显示有数据的项
const filteredData = pieData.filter(item => item.value > 0);
// 计算完成率用于中间显示
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="w-full h-40">
<div className="w-full h-20">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={filteredData}
cx="50%"
cy="50%"
labelLine={{ stroke: '#999', strokeWidth: 1 }}
outerRadius={50}
fill="#8884d8"
innerRadius={25}
outerRadius={35}
paddingAngle={2}
dataKey="value"
label={({ name, value }) => `${name}${value}`}
>
{filteredData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
<Label
value={`${completionRate}%`}
position="center"
className="text-sm font-bold fill-gray-700"
/>
</Pie>
<RechartsTooltip formatter={(value) => [`${value}`, '数量']} />
<Legend layout="vertical" verticalAlign="middle" align="right" formatter={(value, entry) => `${value} ${entry.payload?.value || 0}`} />
<RechartsTooltip
formatter={(value: any) => [`${value}`, '数量']}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', fontSize: '12px', padding: '8px' }}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: '12px' }}
formatter={(value, entry: any) => <span className="text-xs text-gray-600 ml-1">{value} {entry.payload.value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
@@ -328,4 +354,4 @@ const AdminDashboardPage = () => {
);
};
export default AdminDashboardPage;
export default AdminDashboardPage;

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import { Card, Form, Input, Button, message, Select } from 'antd';
import { Card, Form, Input, Button, message, Select, Layout } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts';
import { adminAPI } from '../../services/api';
import { Logo } from '../../components/common/Logo';
const { Header, Content, Footer } = Layout;
// 定义登录记录类型 - 不再保存密码
interface LoginRecord {
@@ -90,109 +93,118 @@ const AdminLoginPage = () => {
};
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>
<Layout className="min-h-screen bg-gray-50">
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
<Logo variant="primary" />
</Header>
<Form
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
form={form}
>
{/* 最近登录记录下拉选择 */}
{loginRecords.length > 0 && (
<Form.Item label="最近登录" className="mb-4">
<Select
size="large"
placeholder="选择最近登录记录"
className="w-full rounded-lg"
style={{ height: 'auto' }} // 让选择框高度自适应内容
onSelect={(value) => {
const record = loginRecords.find(r => `${r.username}-${r.timestamp}` === value);
if (record) {
handleSelectRecord(record);
}
}}
options={loginRecords.map(record => ({
value: `${record.username}-${record.timestamp}`,
label: (
<div className="flex flex-col p-1" style={{ minHeight: '40px', justifyContent: 'center' }}>
<span className="font-medium block">{record.username}</span>
<span className="text-xs text-gray-500 block">
{new Date(record.timestamp).toLocaleString()}
</span>
</div>
)
}))}
<Content className="flex items-center justify-center p-4 bg-gradient-to-br from-mars-50 to-white">
<div className="w-full max-w-md">
<Card className="shadow-xl border-t-4 border-t-mars-500">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-mars-600 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
<Form
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
form={form}
size="large"
>
{/* 最近登录记录下拉选择 */}
{loginRecords.length > 0 && (
<Form.Item label="最近登录" className="mb-4">
<Select
placeholder="选择最近登录记录"
className="w-full"
style={{ height: 'auto' }} // 让选择框高度自适应内容
onSelect={(value) => {
const record = loginRecords.find(r => `${r.username}-${r.timestamp}` === value);
if (record) {
handleSelectRecord(record);
}
}}
options={loginRecords.map(record => ({
value: `${record.username}-${record.timestamp}`,
label: (
<div className="flex flex-col p-1" style={{ minHeight: '40px', justifyContent: 'center' }}>
<span className="font-medium block">{record.username}</span>
<span className="text-xs text-gray-500 block">
{new Date(record.timestamp).toLocaleString()}
</span>
</div>
)
}))}
/>
</Form.Item>
)}
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
className="mb-4"
>
<Input
placeholder="请输入用户名"
autoComplete="username" // 正确的自动完成属性
/>
</Form.Item>
)}
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
className="mb-4"
>
<Input
placeholder="请输入用户名"
size="large"
className="rounded-lg"
autoComplete="username" // 正确的自动完成属性
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' }
]}
className="mb-4"
>
<Input.Password
placeholder="请输入密码"
size="large"
className="rounded-lg"
autoComplete="new-password" // 防止自动填充密码
allowClear // 允许清空密码
/>
</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"
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' }
]}
className="mb-4"
>
</Button>
</Form.Item>
</Form>
<Input.Password
placeholder="请输入密码"
autoComplete="new-password" // 防止自动填充密码
allowClear // 允许清空密码
/>
</Form.Item>
<div className="mt-6 text-center">
<a
href="/"
className="text-blue-600 hover:text-blue-800 text-sm"
>
</a>
</div>
</Card>
</div>
</div>
<Form.Item className="mb-0 mt-8">
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="bg-mars-500 hover:!bg-mars-600 border-none shadow-md h-12 text-lg font-medium"
>
</Button>
</Form.Item>
</Form>
<div className="mt-6 text-center">
<a
href="/"
className="text-mars-600 hover:text-mars-800 text-sm transition-colors"
>
</a>
</div>
</Card>
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
<div className="mb-4 md:mb-0">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<Logo variant="secondary" />
</Footer>
</Layout>
);
};
export default AdminLoginPage;
export default AdminLoginPage;

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select } from 'antd';
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select, Tabs, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import api from '../../services/api';
import api, { userGroupAPI } from '../../services/api';
import dayjs from 'dayjs';
interface ExamTask {
@@ -16,6 +16,7 @@ interface ExamTask {
passRate: number;
excellentRate: number;
createdAt: string;
selectionConfig?: string;
}
interface ExamSubject {
@@ -29,28 +30,40 @@ interface User {
phone: string;
}
interface UserGroup {
id: string;
name: string;
memberCount: number;
}
const ExamTaskPage = () => {
const [tasks, setTasks] = useState<ExamTask[]>([]);
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
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();
// Cache for group members to calculate unique users
const [groupMembersMap, setGroupMembersMap] = useState<Record<string, string[]>>({});
const fetchData = async () => {
setLoading(true);
try {
const [tasksRes, subjectsRes, usersRes] = await Promise.all([
const [tasksRes, subjectsRes, usersRes, groupsRes] = await Promise.all([
api.get('/admin/tasks'),
api.get('/admin/subjects'),
api.get('/admin/users'),
userGroupAPI.getAll(),
]);
setTasks(tasksRes.data);
setSubjects(subjectsRes.data);
setUsers(usersRes.data);
setUserGroups(groupsRes);
} catch (error) {
message.error('获取数据失败');
} finally {
@@ -62,6 +75,44 @@ const ExamTaskPage = () => {
fetchData();
}, []);
// Watch form values for real-time calculation
const selectedUserIds = Form.useWatch('userIds', form) || [];
const selectedGroupIds = Form.useWatch('groupIds', form) || [];
// Fetch members when groups are selected
useEffect(() => {
const fetchMissingGroupMembers = async () => {
if (selectedGroupIds.length > 0) {
for (const gid of selectedGroupIds) {
if (!groupMembersMap[gid]) {
try {
const members = await userGroupAPI.getMembers(gid);
setGroupMembersMap(prev => ({
...prev,
[gid]: members.map((u: any) => u.id)
}));
} catch (e) {
console.error(`Failed to fetch members for group ${gid}`, e);
}
}
}
}
};
if (modalVisible) {
fetchMissingGroupMembers();
}
}, [selectedGroupIds, modalVisible]);
const uniqueUserCount = useMemo(() => {
const uniqueSet = new Set<string>(selectedUserIds);
selectedGroupIds.forEach((gid: string) => {
const members = groupMembersMap[gid] || [];
members.forEach(uid => uniqueSet.add(uid));
});
return uniqueSet.size;
}, [selectedUserIds, selectedGroupIds, groupMembersMap]);
const handleCreate = () => {
setEditingTask(null);
form.resetFields();
@@ -71,9 +122,27 @@ const ExamTaskPage = () => {
const handleEdit = async (task: ExamTask) => {
setEditingTask(task);
try {
// 获取任务已分配的用户列表
const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`);
const userIds = userIdsRes.data;
// Parse selection config if available
let userIds = [];
let groupIds = [];
if (task.selectionConfig) {
try {
const config = JSON.parse(task.selectionConfig);
userIds = config.userIds || [];
groupIds = config.groupIds || [];
} catch (e) {
console.error('Failed to parse selection config', e);
}
}
// Fallback or if no selection config (legacy tasks), fetch from API which returns resolved users
// But for editing legacy tasks, we might not have group info.
// If selectionConfig is missing, we assume individual users only.
if (!task.selectionConfig) {
const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`);
userIds = userIdsRes.data;
}
form.setFieldsValue({
name: task.name,
@@ -81,17 +150,10 @@ const ExamTaskPage = () => {
startAt: dayjs(task.startAt),
endAt: dayjs(task.endAt),
userIds: userIds,
groupIds: groupIds,
});
} catch (error) {
message.error('获取任务用户失败');
// 即使获取失败,也要打开模态框,只是用户列表为空
form.setFieldsValue({
name: task.name,
subjectId: task.subjectId,
startAt: dayjs(task.startAt),
endAt: dayjs(task.endAt),
userIds: [],
});
message.error('获取任务详情失败');
}
setModalVisible(true);
};
@@ -119,6 +181,12 @@ const ExamTaskPage = () => {
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (uniqueUserCount === 0) {
message.warning('请至少选择一位用户或一个用户组');
return;
}
const payload = {
...values,
startAt: values.startAt.toISOString(),
@@ -144,11 +212,15 @@ const ExamTaskPage = () => {
title: '任务名称',
dataIndex: 'name',
key: 'name',
width: 250,
ellipsis: true,
},
{
title: '考试科目',
dataIndex: 'subjectName',
key: 'subjectName',
width: 200,
ellipsis: true,
},
{
title: '开始时间',
@@ -233,8 +305,9 @@ const ExamTaskPage = () => {
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: ExamTask) => (
<Space>
<Space direction="vertical" size={0}>
<Button
type="text"
icon={<EditOutlined />}
@@ -349,32 +422,67 @@ const ExamTaskPage = () => {
/>
</Form.Item>
<Form.Item
name="userIds"
label="参与用户"
rules={[{ required: true, message: '请选择参与用户' }]}
>
<Select
mode="multiple"
placeholder="请选择参与用户"
style={{ width: '100%' }}
showSearch
filterOption={(input, option) => {
const value = option?.children as string;
return value.toLowerCase().includes(input.toLowerCase());
}}
maxTagCount={3}
maxTagPlaceholder={(count) => `+${count} 个用户`}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
virtual
<div className="bg-gray-50 p-4 rounded mb-4">
<h4 className="mb-2 font-medium"></h4>
<Form.Item
name="groupIds"
label="按用户组选择"
>
{users.map((user) => (
<Select.Option key={user.id} value={user.id}>
{user.name} ({user.phone})
</Select.Option>
))}
</Select>
</Form.Item>
<Select
mode="multiple"
placeholder="请选择用户组"
style={{ width: '100%' }}
optionFilterProp="children"
>
{userGroups.map((group) => (
<Select.Option key={group.id} value={group.id}>
{group.name} ({group.memberCount})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="userIds"
label="按单个用户选择"
normalize={(value) => {
if (!Array.isArray(value)) return value;
return [...value].sort((a, b) => {
const nameA = users.find(u => u.id === a)?.name || '';
const nameB = users.find(u => u.id === b)?.name || '';
return nameA.localeCompare(nameB, 'zh-CN');
});
}}
>
<Select
mode="multiple"
placeholder="请选择用户"
style={{ width: '100%' }}
className="user-select-scrollable"
showSearch
optionLabelProp="label"
filterOption={(input, option) => {
const label = option?.label as string;
if (label && label.toLowerCase().includes(input.toLowerCase())) return true;
const children = React.Children.toArray(option?.children).join('');
return children.toLowerCase().includes(input.toLowerCase());
}}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
virtual
>
{users.map((user) => (
<Select.Option key={user.id} value={user.id} label={user.name}>
{user.name} ({user.phone})
</Select.Option>
))}
</Select>
</Form.Item>
<div className="mt-2 text-right text-gray-500">
<span className="font-bold text-blue-600">{uniqueUserCount}</span>
</div>
</div>
</Form>
</Modal>

View File

@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
import { userGroupAPI } from '../../services/api';
import dayjs from 'dayjs';
interface UserGroup {
id: string;
name: string;
description: string;
isSystem: boolean;
createdAt: string;
memberCount: number;
}
const UserGroupManage = () => {
const [groups, setGroups] = useState<UserGroup[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingGroup, setEditingGroup] = useState<UserGroup | null>(null);
const [form] = Form.useForm();
const fetchGroups = async () => {
setLoading(true);
try {
const res = await userGroupAPI.getAll();
setGroups(res);
} catch (error) {
message.error('获取用户组列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchGroups();
}, []);
const handleCreate = () => {
setEditingGroup(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (group: UserGroup) => {
if (group.isSystem) {
message.warning('系统内置用户组无法修改');
return;
}
setEditingGroup(group);
form.setFieldsValue({
name: group.name,
description: group.description,
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await userGroupAPI.delete(id);
message.success('删除成功');
fetchGroups();
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingGroup) {
await userGroupAPI.update(editingGroup.id, values);
message.success('更新成功');
} else {
await userGroupAPI.create(values);
message.success('创建成功');
}
setModalVisible(false);
fetchGroups();
} catch (error: any) {
message.error(error.message || '操作失败');
}
};
const columns = [
{
title: '组名',
dataIndex: 'name',
key: 'name',
render: (text: string, record: UserGroup) => (
<Space>
{text}
{record.isSystem && <Tag color="blue"></Tag>}
</Space>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '成员数',
dataIndex: 'memberCount',
key: 'memberCount',
render: (count: number) => (
<Space>
<TeamOutlined />
{count}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
render: (_: any, record: UserGroup) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
disabled={record.isSystem}
>
</Button>
{!record.isSystem && (
<Popconfirm
title="确定删除该用户组吗?"
description="删除后,组内成员将自动解除与该组的关联"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div className="flex justify-end mb-4">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={groups}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10 }}
/>
<Modal
title={editingGroup ? '编辑用户组' : '新增用户组'}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="组名"
rules={[
{ required: true, message: '请输入组名' },
{ min: 2, max: 20, message: '组名长度在2-20个字符之间' }
]}
>
<Input placeholder="请输入组名" />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea placeholder="请输入描述" rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserGroupManage;

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload } from 'antd';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import api from '../../services/api';
import api, { userGroupAPI } from '../../services/api';
import type { UploadProps } from 'antd';
import UserGroupManage from './UserGroupManage';
interface User {
id: string;
@@ -12,6 +13,7 @@ interface User {
createdAt: string;
examCount?: number; // 参加考试次数
lastExamTime?: string; // 最后一次参加考试时间
groups?: any[];
}
interface QuizRecord {
@@ -25,21 +27,9 @@ interface QuizRecord {
taskName?: string;
}
interface QuizRecordDetail {
id: string;
question: {
content: string;
type: string;
options?: string[];
};
userAnswer: string | string[];
correctAnswer: string | string[];
score: number;
isCorrect: boolean;
}
const UserManagePage = () => {
const [users, setUsers] = useState<User[]>([]);
const [userGroups, setUserGroups] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
@@ -92,8 +82,18 @@ const UserManagePage = () => {
}
};
const fetchUserGroups = async () => {
try {
const res = await userGroupAPI.getAll();
setUserGroups(res);
} catch (error) {
console.error('获取用户组失败');
}
};
useEffect(() => {
fetchUsers();
fetchUserGroups();
}, []);
const handleTableChange = (newPagination: any) => {
@@ -113,6 +113,11 @@ const UserManagePage = () => {
const handleCreate = () => {
setEditingUser(null);
form.resetFields();
// Set default groups (e.g. system group)
const systemGroups = userGroups.filter(g => g.isSystem).map(g => g.id);
form.setFieldsValue({ groupIds: systemGroups });
setModalVisible(true);
};
@@ -122,6 +127,7 @@ const UserManagePage = () => {
name: user.name,
phone: user.phone,
password: user.password,
groupIds: user.groups?.map(g => g.id) || []
});
setModalVisible(true);
};
@@ -292,6 +298,18 @@ const UserManagePage = () => {
dataIndex: 'phone',
key: 'phone',
},
{
title: '用户组',
dataIndex: 'groups',
key: 'groups',
render: (groups: any[]) => (
<Space size={[0, 4]} wrap>
{groups?.map(g => (
<Tag key={g.id} color={g.isSystem ? 'blue' : 'default'}>{g.name}</Tag>
))}
</Space>
)
},
{
title: '密码',
dataIndex: 'password',
@@ -367,10 +385,9 @@ const UserManagePage = () => {
beforeUpload: handleImport,
};
return (
const UserListContent = () => (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-800 mb-4"></h1>
<div className="flex justify-between items-center">
<Input
placeholder="按姓名搜索"
@@ -514,6 +531,22 @@ const UserManagePage = () => {
>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item
name="groupIds"
label="所属用户组"
>
<Select
mode="multiple"
placeholder="请选择用户组"
optionFilterProp="children"
>
{userGroups.map(g => (
<Select.Option key={g.id} value={g.id} disabled={g.isSystem}>
{g.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
@@ -614,6 +647,27 @@ const UserManagePage = () => {
</Modal>
</div>
);
return (
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-4"></h1>
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: '用户列表',
children: <UserListContent />,
},
{
key: '2',
label: '用户组管理',
children: <UserGroupManage />,
},
]}
/>
</div>
);
};
export default UserManagePage;
export default UserManagePage;

View File

@@ -114,4 +114,13 @@ export const adminAPI = {
api.put('/admin/password', data),
};
// 用户组相关API
export const userGroupAPI = {
getAll: () => api.get('/admin/user-groups'),
create: (data: { name: string; description?: string }) => api.post('/admin/user-groups', data),
update: (id: string, data: { name?: string; description?: string }) => api.put(`/admin/user-groups/${id}`, data),
delete: (id: string) => api.delete(`/admin/user-groups/${id}`),
getMembers: (id: string) => api.get(`/admin/user-groups/${id}/members`),
};
export default api;