基本功能完成,下一步开始美化UI
This commit is contained in:
75
src/components/common/Logo.tsx
Normal file
75
src/components/common/Logo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
© {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;
|
||||
|
||||
28
src/layouts/UserLayout.tsx
Normal file
28
src/layouts/UserLayout.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
35
src/main.tsx
35
src/main.tsx
@@ -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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
export default HomePage;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
export default AdminLoginPage;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
199
src/pages/admin/UserGroupManage.tsx
Normal file
199
src/pages/admin/UserGroupManage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user