Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abfe6e95f9 | |||
| eec3ea2238 |
BIN
.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-311.pyc
Normal file
BIN
.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-311.pyc
Normal file
Binary file not shown.
@@ -116,9 +116,16 @@ export class QuizController {
|
||||
userAnsList = answer.userAnswer;
|
||||
} else if (typeof answer.userAnswer === 'string') {
|
||||
try {
|
||||
userAnsList = JSON.parse(answer.userAnswer);
|
||||
const parsed = JSON.parse(answer.userAnswer);
|
||||
if (Array.isArray(parsed)) {
|
||||
userAnsList = parsed;
|
||||
} else {
|
||||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||||
userAnsList = answer.userAnswer.split(',').map(item => item.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
userAnsList = [answer.userAnswer];
|
||||
// JSON解析失败,尝试按逗号分割
|
||||
userAnsList = answer.userAnswer.split(',').map(item => item.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +135,15 @@ export class QuizController {
|
||||
} else if (typeof question.answer === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(question.answer);
|
||||
if (Array.isArray(parsed)) correctAnsList = parsed;
|
||||
else correctAnsList = [question.answer];
|
||||
if (Array.isArray(parsed)) {
|
||||
correctAnsList = parsed;
|
||||
} else {
|
||||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||||
correctAnsList = question.answer.split(',').map(item => item.trim());
|
||||
}
|
||||
} catch {
|
||||
correctAnsList = [question.answer];
|
||||
// JSON解析失败,尝试按逗号分割
|
||||
correctAnsList = question.answer.split(',').map(item => item.trim());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
681
deploy_bundle/web/assets/index-af8cb0cc.js
Normal file
681
deploy_bundle/web/assets/index-af8cb0cc.js
Normal file
File diff suppressed because one or more lines are too long
1
deploy_bundle/web/assets/index-f369cdf2.css
Normal file
1
deploy_bundle/web/assets/index-f369cdf2.css
Normal file
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宝来威考试平台</title>
|
||||
<meta name="description" content="功能完善的在线问卷调查系统,支持多种题型、随机抽题、免注册答题等特性" />
|
||||
<script type="module" crossorigin src="/assets/index-38e9e7a4.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-acd65452.css">
|
||||
<script type="module" crossorigin src="/assets/index-af8cb0cc.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-f369cdf2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -188,6 +188,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1539,6 +1540,7 @@
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2028,6 +2030,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2793,6 +2796,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
@@ -2808,7 +2812,8 @@
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3938,6 +3943,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -4944,6 +4950,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -5877,6 +5884,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -5889,6 +5897,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -5909,6 +5918,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -6040,7 +6050,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -6941,6 +6952,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7014,6 +7026,7 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -7705,6 +7718,7 @@
|
||||
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.18.10",
|
||||
"postcss": "^8.4.27",
|
||||
|
||||
@@ -8,6 +8,7 @@ import QuizPage from './pages/QuizPage';
|
||||
import ResultPage from './pages/ResultPage';
|
||||
import { SubjectSelectionPage } from './pages/SubjectSelectionPage';
|
||||
import { UserTaskPage } from './pages/UserTaskPage';
|
||||
import BankingLandingPage from './pages/BankingLandingPage';
|
||||
|
||||
// 管理端页面
|
||||
import AdminLoginPage from './pages/admin/AdminLoginPage';
|
||||
@@ -41,6 +42,7 @@ function App() {
|
||||
<Route path="/tasks" element={<UserTaskPage />} />
|
||||
<Route path="/quiz" element={<QuizPage />} />
|
||||
<Route path="/result/:id" element={<ResultPage />} />
|
||||
<Route path="/banking-demo" element={<BankingLandingPage />} />
|
||||
|
||||
{/* 管理端路由 */}
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
|
||||
29
src/components/common/GlassCard.tsx
Normal file
29
src/components/common/GlassCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Card, CardProps } from 'antd';
|
||||
|
||||
interface GlassCardProps extends CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GlassCard: React.FC<GlassCardProps> = ({ children, className = '', style, ...props }) => {
|
||||
return (
|
||||
<Card
|
||||
className={`glass-card ${className}`}
|
||||
bordered={false}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.36)',
|
||||
color: 'white',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlassCard;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface Question {
|
||||
export interface QuizQuestion {
|
||||
id: string;
|
||||
content: string;
|
||||
type: 'single' | 'multiple' | 'judgment' | 'text';
|
||||
@@ -8,13 +8,13 @@ interface Question {
|
||||
answer: string | string[];
|
||||
analysis?: string;
|
||||
score: number;
|
||||
createdAt: string;
|
||||
createdAt?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface QuizContextType {
|
||||
questions: Question[];
|
||||
setQuestions: (questions: Question[]) => void;
|
||||
questions: QuizQuestion[];
|
||||
setQuestions: (questions: QuizQuestion[]) => void;
|
||||
currentQuestionIndex: number;
|
||||
setCurrentQuestionIndex: (index: number) => void;
|
||||
answers: Record<string, string | string[]>;
|
||||
@@ -26,7 +26,7 @@ interface QuizContextType {
|
||||
const QuizContext = createContext<QuizContextType | undefined>(undefined);
|
||||
|
||||
export const QuizProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { UserProvider, useUser } from './UserContext';
|
||||
export { AdminProvider, useAdmin } from './AdminContext';
|
||||
export { QuizProvider, useQuiz } from './QuizContext';
|
||||
export { QuizProvider, useQuiz, type QuizQuestion } from './QuizContext';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, App, ConfigProvider, theme } from 'antd';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
@@ -25,6 +25,7 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { admin, clearAdmin } = useAdmin();
|
||||
const { message } = App.useApp();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const menuItems = [
|
||||
@@ -91,65 +92,122 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme="light"
|
||||
className="shadow-md z-10 fixed left-0 top-0 h-screen overflow-y-auto"
|
||||
width={240}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-center border-b border-gray-100">
|
||||
{collapsed ? (
|
||||
<img src={正方形LOGO} alt="正方形LOGO" style={{ width: '24px', height: '32px' }} />
|
||||
) : (
|
||||
<img src={主要LOGO} alt="主要LOGO" style={{ width: '180px', height: '72px' }} />
|
||||
)}
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#22D3EE', // Cyan-400
|
||||
colorBgContainer: 'rgba(255, 255, 255, 0.05)',
|
||||
colorBgElevated: 'rgba(30, 41, 59, 0.95)', // Slate-800
|
||||
borderRadius: 12,
|
||||
colorText: 'rgba(255, 255, 255, 0.9)',
|
||||
colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
bodyBg: 'transparent',
|
||||
headerBg: 'rgba(255, 255, 255, 0.03)',
|
||||
siderBg: 'rgba(255, 255, 255, 0.03)',
|
||||
},
|
||||
Menu: {
|
||||
itemBg: 'transparent',
|
||||
itemSelectedBg: 'rgba(34, 211, 238, 0.15)',
|
||||
itemSelectedColor: '#22D3EE',
|
||||
},
|
||||
Table: {
|
||||
headerBg: 'rgba(255, 255, 255, 0.05)',
|
||||
headerColor: 'rgba(255, 255, 255, 0.85)',
|
||||
rowHoverBg: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100vh', background: '#0A1628', position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Ambient Background Effects */}
|
||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/10 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-cyan-600/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
className="py-4"
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout className="bg-gray-50/50 ml-0 transition-all duration-300" style={{ marginLeft: collapsed ? 80 : 240 }}>
|
||||
<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 ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-lg w-10 h-10 flex items-center justify-center"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-600 hidden sm:block">
|
||||
欢迎您,{admin?.username}
|
||||
</span>
|
||||
<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 flex flex-col pb-20">
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-white text-center py-1.5 px-2 text-gray-400 text-xs flex flex-col md:flex-row justify-center items-center fixed bottom-0 left-0 right-0 shadow-sm" style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.3s' }}>
|
||||
<div className="whitespace-nowrap">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={240}
|
||||
className="z-10 border-r border-white/5 backdrop-blur-md"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center py-6 px-4">
|
||||
<img
|
||||
src={collapsed ? 正方形LOGO : 主要LOGO}
|
||||
alt="Logo"
|
||||
className={`transition-all duration-300 ${collapsed ? 'w-10' : 'h-10'}`}
|
||||
style={{ filter: 'brightness(0) invert(1)' }}
|
||||
/>
|
||||
</div>
|
||||
</Footer>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="border-none px-2"
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout className="z-10 bg-transparent">
|
||||
<Header
|
||||
className="flex items-center justify-between px-6 border-b border-white/5 backdrop-blur-md sticky top-0 z-20"
|
||||
style={{
|
||||
padding: 0,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
height: 64,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 64,
|
||||
height: 64,
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 px-6">
|
||||
<span className="text-white/80 text-sm hidden sm:inline-block">
|
||||
欢迎回来,{admin?.username}
|
||||
</span>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" arrow>
|
||||
<div className="flex items-center gap-2 cursor-pointer hover:bg-white/5 px-3 py-2 rounded-lg transition-colors">
|
||||
<Avatar
|
||||
style={{ backgroundColor: '#22D3EE', verticalAlign: 'middle' }}
|
||||
icon={<UserOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
<span className="text-white/90 font-medium hidden sm:inline-block">
|
||||
管理员
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="m-6 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer className="text-center bg-transparent text-white/30 py-4">
|
||||
Survey System ©{new Date().getFullYear()} Created by BLV
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
300
src/pages/BankingLandingPage.tsx
Normal file
300
src/pages/BankingLandingPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Icons as components to avoid dependency issues
|
||||
const MenuIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
|
||||
);
|
||||
const ShieldIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg>
|
||||
);
|
||||
const LockIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
);
|
||||
const ZapIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
);
|
||||
const GlobeIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||
);
|
||||
const ArrowRightIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
);
|
||||
const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
);
|
||||
const CreditCardIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
||||
);
|
||||
|
||||
const BankingLandingPage = () => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A1628] text-white overflow-x-hidden font-sans selection:bg-cyan-500/30">
|
||||
{/* Background Gradients */}
|
||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden -z-10 pointer-events-none">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/20 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-cyan-600/20 rounded-full blur-[120px]" />
|
||||
<div className="absolute top-[20%] right-[10%] w-[20%] h-[20%] bg-indigo-600/20 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Navbar */}
|
||||
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? 'bg-[#0A1628]/80 backdrop-blur-md border-b border-white/10 py-4' : 'bg-transparent py-6'}`}>
|
||||
<div className="container mx-auto px-6 max-w-7xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-bold text-2xl tracking-tight">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<span className="text-white">N</span>
|
||||
</div>
|
||||
<span>Nova<span className="text-cyan-400">Bank</span></span>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-300">
|
||||
<a href="#features" className="hover:text-white transition-colors">Features</a>
|
||||
<a href="#security" className="hover:text-white transition-colors">Security</a>
|
||||
<a href="#pricing" className="hover:text-white transition-colors">Pricing</a>
|
||||
<a href="#about" className="hover:text-white transition-colors">About</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="hidden md:block text-sm font-medium hover:text-white text-slate-300 transition-colors">Log In</button>
|
||||
<button className="bg-white text-[#0A1628] px-5 py-2.5 rounded-full text-sm font-bold hover:bg-cyan-50 transition-colors shadow-lg shadow-white/10">
|
||||
Open Account
|
||||
</button>
|
||||
<button className="md:hidden text-white">
|
||||
<MenuIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-20 md:pt-48 md:pb-32 relative">
|
||||
<div className="container mx-auto px-6 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row items-center gap-16">
|
||||
<div className="flex-1 space-y-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-semibold uppercase tracking-wider">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Banking 3.0 is here
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-bold leading-tight">
|
||||
Banking for the <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-cyan-400 to-teal-300">
|
||||
Digital Age
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg text-slate-400 max-w-xl leading-relaxed">
|
||||
Experience the future of finance with instant transactions, bank-grade security, and beautiful analytics. No hidden fees, ever.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<button className="px-8 py-4 rounded-full bg-gradient-to-r from-blue-600 to-cyan-500 text-white font-bold hover:shadow-lg hover:shadow-blue-500/25 transition-all transform hover:-translate-y-1 flex items-center justify-center gap-2">
|
||||
Get Started <ArrowRightIcon />
|
||||
</button>
|
||||
<button className="px-8 py-4 rounded-full bg-white/5 border border-white/10 text-white font-semibold hover:bg-white/10 transition-all flex items-center justify-center gap-2 backdrop-blur-sm">
|
||||
Download App
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 flex items-center gap-4 text-sm text-slate-500">
|
||||
<div className="flex -space-x-2">
|
||||
{[1,2,3,4].map(i => (
|
||||
<div key={i} className="w-8 h-8 rounded-full border-2 border-[#0A1628] bg-slate-700" />
|
||||
))}
|
||||
</div>
|
||||
<p>Trusted by 100,000+ users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
{/* Glass Card - Main */}
|
||||
<div className="relative z-20 bg-white/10 backdrop-blur-xl border border-white/20 p-8 rounded-3xl shadow-2xl shadow-blue-900/20 transform rotate-[-3deg] hover:rotate-0 transition-all duration-500">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">Total Balance</p>
|
||||
<h3 className="text-3xl font-bold mt-1">$124,500.80</h3>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<CreditCardIcon />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Netflix</p>
|
||||
<p className="text-xs text-slate-400">Subscription</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-medium text-white">-$15.99</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500/20 text-green-400 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Salary</p>
|
||||
<p className="text-xs text-slate-400">Deposit</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-medium text-green-400">+$4,250.00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-white/10 flex justify-between items-center text-sm">
|
||||
<span className="text-slate-400">**** 4582</span>
|
||||
<span className="text-white font-medium">VISA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements behind card */}
|
||||
<div className="absolute top-10 -right-4 w-full h-full bg-blue-600/30 rounded-3xl blur-2xl -z-10" />
|
||||
<div className="absolute -bottom-10 -left-10 w-24 h-24 bg-cyan-400 rounded-full blur-2xl opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section id="features" className="py-24 relative">
|
||||
<div className="container mx-auto px-6 max-w-7xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-6">Everything you need</h2>
|
||||
<p className="text-slate-400">We've built a platform that handles all your financial needs with precision and style.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: <ZapIcon />,
|
||||
title: "Instant Transfers",
|
||||
desc: "Send money to anyone, anywhere in the world in seconds. No waiting days."
|
||||
},
|
||||
{
|
||||
icon: <ShieldIcon />,
|
||||
title: "Bank-Grade Security",
|
||||
desc: "Your data is protected by 256-bit encryption and advanced fraud detection."
|
||||
},
|
||||
{
|
||||
icon: <GlobeIcon />,
|
||||
title: "Global Spending",
|
||||
desc: "Spend in over 150 currencies with the real exchange rate and no hidden markups."
|
||||
}
|
||||
].map((feature, idx) => (
|
||||
<div key={idx} className="p-8 rounded-3xl bg-white/5 border border-white/10 hover:bg-white/10 transition-all hover:-translate-y-2 group">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center text-cyan-400 mb-6 group-hover:scale-110 transition-transform">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4">{feature.title}</h3>
|
||||
<p className="text-slate-400 leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security Section */}
|
||||
<section id="security" className="py-24 bg-[#050B14] relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-blue-900 to-transparent" />
|
||||
|
||||
<div className="container mx-auto px-6 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row items-center gap-16">
|
||||
<div className="flex-1 order-2 md:order-1">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-blue-500/20 blur-[80px] rounded-full" />
|
||||
<div className="relative z-10 bg-[#0A1628] border border-blue-500/30 p-8 rounded-2xl max-w-md mx-auto">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center text-green-500">
|
||||
<LockIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold">Security Alert</h4>
|
||||
<p className="text-xs text-slate-400">Just now</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-300 mb-6">We noticed a login from a new device. Was this you?</p>
|
||||
<div className="flex gap-3">
|
||||
<button className="flex-1 py-2 bg-blue-600 rounded-lg text-sm font-bold hover:bg-blue-500 transition-colors">Yes, it's me</button>
|
||||
<button className="flex-1 py-2 bg-white/10 rounded-lg text-sm font-bold hover:bg-white/20 transition-colors">No, block it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 order-1 md:order-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-xs font-semibold uppercase tracking-wider mb-6">
|
||||
<ShieldIcon /> Unbreakable Security
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-6">Your money is safe with us</h2>
|
||||
<p className="text-slate-400 text-lg mb-8">
|
||||
We use state-of-the-art encryption and biometric verification to ensure your account is impenetrable.
|
||||
</p>
|
||||
<ul className="space-y-4">
|
||||
{['Biometric Authentication', 'Instant Freeze & Unfreeze', 'Real-time Transaction Alerts', '24/7 Fraud Monitoring'].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300">
|
||||
<div className="w-6 h-6 rounded-full bg-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 relative">
|
||||
<div className="container mx-auto px-6 max-w-7xl text-center">
|
||||
<div className="bg-gradient-to-b from-blue-900/40 to-transparent p-12 md:p-20 rounded-[3rem] border border-blue-500/20 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full bg-blue-500/10 blur-[100px] -z-10" />
|
||||
|
||||
<h2 className="text-4xl md:text-6xl font-bold mb-8">Ready to start?</h2>
|
||||
<p className="text-xl text-slate-400 mb-10 max-w-2xl mx-auto">
|
||||
Join over 100,000 users who are managing their finances smarter, faster, and better.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<button className="px-8 py-4 rounded-full bg-white text-[#0A1628] font-bold hover:bg-cyan-50 transition-colors shadow-lg shadow-white/10 flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.74 1.18 0 2.21-.93 3.69-.74 2.4.29 4.18 1.8 4.18 1.83s-2.25 1.4-1.9 4.96c.16 1.7 1.09 3.05 1.09 3.05s-1.57 3.34-2.14 3.13zM12.03 5.31c.36-1.74 1.4-2.97 2.92-3.31-.2 1.88-1.4 3.4-3.02 3.35.04-.01.07-.02.1-.04z"/></svg>
|
||||
App Store
|
||||
</button>
|
||||
<button className="px-8 py-4 rounded-full bg-transparent border border-white/20 text-white font-bold hover:bg-white/10 transition-colors flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"><path d="M3.609 1.814L13.792 12 3.61 22.186a.996.996 0 0 1-.61-.92V2.734a1 1 0 0 1 .609-.92zm11.83 11.536l5.58 5.58a.997.997 0 0 0 1.503-.13l.004-.005c1.176-1.685 2.14-4.59 2.14-7.795 0-3.206-.964-6.11-2.14-7.795a.999.999 0 0 0-1.508-.135l-5.58 5.58 1.35 1.35 1.35 1.35zM12.38 13.414L2.876 22.918c.363.155.772.106 1.088-.13l9.766-9.766-1.35-1.35zM2.876 1.082L12.38 10.586 11.03 9.236 1.284 1.212a.998.998 0 0 1 1.592-.13z"/></svg>
|
||||
Google Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 border-t border-white/10 text-sm text-slate-500">
|
||||
<div className="container mx-auto px-6 max-w-7xl flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<p>© 2024 NovaBank Inc. All rights reserved.</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-white transition-colors">Privacy</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Terms</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Cookies</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BankingLandingPage;
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 're
|
||||
import { Card, Button, Modal, App } from 'antd';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useUser, useQuiz } from '../contexts';
|
||||
import type { QuizQuestion } from '../contexts';
|
||||
import { quizAPI } from '../services/api';
|
||||
import { questionTypeMap } from '../utils/validation';
|
||||
import { detectHorizontalSwipe } from '../utils/swipe';
|
||||
@@ -11,19 +12,10 @@ import { QuizProgress } from './quiz/components/QuizProgress';
|
||||
import { OptionList } from './quiz/components/OptionList';
|
||||
import { QuizFooter } from './quiz/components/QuizFooter';
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
content: string;
|
||||
type: 'single' | 'multiple' | 'judgment' | 'text';
|
||||
options?: string[];
|
||||
answer: string | string[];
|
||||
analysis?: string;
|
||||
score: number;
|
||||
category: string;
|
||||
}
|
||||
type Question = QuizQuestion;
|
||||
|
||||
interface LocationState {
|
||||
questions?: Question[];
|
||||
questions?: QuizQuestion[];
|
||||
totalScore?: number;
|
||||
timeLimit?: number;
|
||||
subjectId?: string;
|
||||
@@ -387,8 +379,28 @@ const QuizPage = () => {
|
||||
if (!userAnswer) return false;
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
const correctAnswers = Array.isArray(question.answer) ? question.answer : [question.answer];
|
||||
const userAnswers = Array.isArray(userAnswer) ? userAnswer : [userAnswer];
|
||||
// 处理正确答案
|
||||
let correctAnswers: string[] = [];
|
||||
if (Array.isArray(question.answer)) {
|
||||
correctAnswers = question.answer;
|
||||
} else if (typeof question.answer === 'string') {
|
||||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||||
correctAnswers = question.answer.split(',').map(item => item.trim());
|
||||
} else {
|
||||
correctAnswers = [String(question.answer)];
|
||||
}
|
||||
|
||||
// 处理用户答案
|
||||
let userAnswers: string[] = [];
|
||||
if (Array.isArray(userAnswer)) {
|
||||
userAnswers = userAnswer;
|
||||
} else if (typeof userAnswer === 'string') {
|
||||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||||
userAnswers = userAnswer.split(',').map(item => item.trim());
|
||||
} else {
|
||||
userAnswers = [String(userAnswer)];
|
||||
}
|
||||
|
||||
return correctAnswers.length === userAnswers.length &&
|
||||
correctAnswers.every(answer => userAnswers.includes(answer));
|
||||
} else {
|
||||
@@ -456,7 +468,7 @@ const QuizPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
||||
<QuizHeader
|
||||
current={currentQuestionIndex + 1}
|
||||
total={questions.length}
|
||||
@@ -465,67 +477,194 @@ const QuizPage = () => {
|
||||
taskName={taskName}
|
||||
/>
|
||||
|
||||
<QuizProgress
|
||||
current={currentQuestionIndex + 1}
|
||||
total={questions.length}
|
||||
/>
|
||||
{/* Mobile Progress Bar */}
|
||||
<div className="lg:hidden">
|
||||
<QuizProgress
|
||||
current={currentQuestionIndex + 1}
|
||||
total={questions.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pb-24 safe-area-bottom">
|
||||
<div className="max-w-md mx-auto px-4">
|
||||
<div
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className={`
|
||||
transition-all duration-300 ease-out
|
||||
${enterAnimationOn
|
||||
? 'opacity-100 translate-x-0'
|
||||
: navDirection === 'next'
|
||||
? 'opacity-0 translate-x-4'
|
||||
: 'opacity-0 -translate-x-4'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-2 min-h-[280px]">
|
||||
<div className="mb-2">
|
||||
<span className={`inline-block px-1 py-0.5 rounded-md text-xs font-medium border ${getTagColor(currentQuestion.type)}`}>
|
||||
<main className="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-full items-start">
|
||||
{/* Left Column: Question Area */}
|
||||
<div className="lg:col-span-9 flex flex-col h-full">
|
||||
<div
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className={`
|
||||
bg-white rounded-2xl shadow-sm border border-gray-200 p-6 sm:p-8 lg:p-10 flex-1 flex flex-col min-h-[60vh] transition-all duration-300 ease-out
|
||||
${enterAnimationOn
|
||||
? 'opacity-100 translate-x-0'
|
||||
: navDirection === 'next'
|
||||
? 'opacity-0 translate-x-4'
|
||||
: 'opacity-0 -translate-x-4'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Question Meta */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="text-3xl font-bold text-gray-200 select-none">
|
||||
{(currentQuestionIndex + 1).toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getTagColor(currentQuestion.type)}`}>
|
||||
{questionTypeMap[currentQuestion.type]}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">
|
||||
/ 共 {questions.length} 题
|
||||
</span>
|
||||
{currentQuestion.category && (
|
||||
<span className="ml-2 inline-block px-1 py-0.5 bg-gray-50 text-gray-600 text-xs rounded border border-gray-100">
|
||||
<span className="ml-auto px-2 py-1 bg-gray-50 text-gray-500 text-xs rounded border border-gray-100">
|
||||
{currentQuestion.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Content */}
|
||||
<h2
|
||||
ref={questionHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-gray-900 leading-tight mb-3 outline-none"
|
||||
className="text-xl sm:text-2xl font-medium text-gray-900 leading-relaxed mb-8 outline-none"
|
||||
>
|
||||
{currentQuestion.content}
|
||||
</h2>
|
||||
|
||||
<OptionList
|
||||
type={currentQuestion.type}
|
||||
options={currentQuestion.options}
|
||||
value={answers[currentQuestion.id]}
|
||||
onChange={(val) => handleAnswerChange(currentQuestion.id, val)}
|
||||
/>
|
||||
{/* Options */}
|
||||
<div className="flex-1">
|
||||
<OptionList
|
||||
type={currentQuestion.type}
|
||||
options={currentQuestion.options}
|
||||
value={answers[currentQuestion.id]}
|
||||
onChange={(val) => handleAnswerChange(currentQuestion.id, val)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation Buttons */}
|
||||
<div className="hidden lg:flex items-center justify-between mt-12 pt-8 border-t border-gray-100">
|
||||
<Button
|
||||
size="large"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
className="px-8 h-12 text-base rounded-xl"
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
{currentQuestionIndex === questions.length - 1 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => handleSubmit()}
|
||||
loading={submitting}
|
||||
className="px-8 h-12 text-base rounded-xl bg-[#008C8C] hover:bg-[#00796B]"
|
||||
>
|
||||
提交试卷
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleNext}
|
||||
className="px-8 h-12 text-base rounded-xl bg-[#008C8C] hover:bg-[#00796B]"
|
||||
>
|
||||
下一题
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sidebar (Desktop Only) */}
|
||||
<div className="hidden lg:flex lg:col-span-3 flex-col gap-6 sticky top-24">
|
||||
{/* User Info Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-[#E0F7FA] flex items-center justify-center text-[#008C8C] text-xl font-bold">
|
||||
{user?.name?.[0] || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">{user?.name}</div>
|
||||
<div className="text-xs text-gray-500">考生</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||||
<span>已完成</span>
|
||||
<span className="font-bold text-[#008C8C]">{answeredCount} / {questions.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer Sheet Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex-1">
|
||||
<h3 className="font-bold text-gray-900 mb-4">答题卡</h3>
|
||||
<div className="grid grid-cols-5 gap-2 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{questions.map((q, idx) => {
|
||||
const isCurrent = idx === currentQuestionIndex;
|
||||
const isAnswered = !!answers[q.id];
|
||||
let className = 'h-10 w-10 rounded-lg flex items-center justify-center text-sm font-medium border transition-all duration-200 ';
|
||||
|
||||
if (isCurrent) {
|
||||
className += 'border-[#008C8C] bg-[#E0F7FA] text-[#006064] ring-2 ring-[#008C8C] ring-offset-2';
|
||||
} else if (isAnswered) {
|
||||
className += 'border-[#008C8C] bg-[#008C8C] text-white';
|
||||
} else {
|
||||
className += 'border-gray-200 text-gray-600 bg-gray-50 hover:border-[#008C8C] hover:bg-white';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => handleJumpTo(idx)}
|
||||
className={className}
|
||||
>
|
||||
{idx + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-100 grid grid-cols-2 gap-3 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-[#008C8C]"></div>
|
||||
<span>已作答</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-[#E0F7FA] border border-[#008C8C]"></div>
|
||||
<span>当前</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-gray-50 border border-gray-200"></div>
|
||||
<span>未作答</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
className="mt-6 bg-[#008C8C] hover:bg-[#00796B] h-12 rounded-xl font-medium"
|
||||
onClick={() => handleSubmit()}
|
||||
>
|
||||
交卷
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Footer */}
|
||||
<div className="lg:hidden">
|
||||
<QuizFooter
|
||||
current={currentQuestionIndex}
|
||||
total={questions.length}
|
||||
onPrev={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onSubmit={() => handleSubmit()}
|
||||
onOpenSheet={() => setAnswerSheetOpen(true)}
|
||||
answeredCount={answeredCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QuizFooter
|
||||
current={currentQuestionIndex}
|
||||
total={questions.length}
|
||||
onPrev={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onSubmit={() => handleSubmit()}
|
||||
onOpenSheet={() => setAnswerSheetOpen(true)}
|
||||
answeredCount={answeredCount}
|
||||
/>
|
||||
|
||||
{/* Mobile Answer Sheet Modal */}
|
||||
<Modal
|
||||
title="答题卡"
|
||||
open={answerSheetOpen}
|
||||
@@ -534,32 +673,33 @@ const QuizPage = () => {
|
||||
centered
|
||||
width={340}
|
||||
destroyOnClose
|
||||
className="mobile-sheet-modal"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-xs text-gray-700">
|
||||
已答 <span className="text-[#00897B] font-medium">{answeredCount}</span> / {questions.length}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
进度:<span className="text-[#008C8C] font-bold">{answeredCount}</span> / {questions.length}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleJumpToFirstUnanswered}
|
||||
className="bg-[#00897B] hover:bg-[#00796B] text-xs h-7 px-3"
|
||||
className="bg-[#008C8C] hover:bg-[#00796B] text-xs h-8 px-4 rounded-lg"
|
||||
>
|
||||
回到当前题
|
||||
跳转未答
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{questions.map((q, idx) => {
|
||||
const isCurrent = idx === currentQuestionIndex;
|
||||
const isAnswered = !!answers[q.id];
|
||||
|
||||
let className = 'h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium border transition-colors ';
|
||||
let className = 'h-10 w-10 rounded-lg flex items-center justify-center text-sm font-medium border transition-colors ';
|
||||
if (isCurrent) {
|
||||
className += 'border-[#00897B] bg-[#E0F2F1] text-[#00695C]';
|
||||
className += 'border-[#008C8C] bg-[#E0F7FA] text-[#006064]';
|
||||
} else if (isAnswered) {
|
||||
className += 'border-[#00897B] bg-[#00897B] text-white';
|
||||
className += 'border-[#008C8C] bg-[#008C8C] text-white';
|
||||
} else {
|
||||
className += 'border-gray-200 text-gray-600 bg-white hover:border-[#00897B]';
|
||||
className += 'border-gray-200 text-gray-600 bg-white hover:border-[#008C8C]';
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -142,9 +142,11 @@ const ResultPage = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(ans);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
return [ans];
|
||||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||||
return String(ans).split(',').map(item => item.trim());
|
||||
} catch {
|
||||
return [ans];
|
||||
// JSON解析失败,尝试按逗号分割
|
||||
return String(ans).split(',').map(item => item.trim());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,7 +243,7 @@ const ResultPage = () => {
|
||||
case '不及格':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case '合格':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
return 'bg-teal-50 text-[#008C8C] border-teal-100';
|
||||
case '优秀':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
default:
|
||||
@@ -251,26 +253,26 @@ const ResultPage = () => {
|
||||
|
||||
return (
|
||||
<UserLayout>
|
||||
<div className="max-w-md mx-auto px-4">
|
||||
<div className="max-w-md mx-auto px-4 py-6">
|
||||
{/* 结果概览 */}
|
||||
<Card className="shadow-lg mb-6 rounded-xl border-t-4 border-t-mars-500" bodyStyle={{ padding: '12px' }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Card className="shadow-sm mb-6 rounded-2xl border-t-4 border-t-[#008C8C]" bodyStyle={{ padding: '20px' }}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<StatusIcon status={record.status} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-800 mb-0.5">
|
||||
<div className="text-base font-bold text-gray-900 mb-1">
|
||||
答题完成!您的得分是 {record.totalScore} 分
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${getStatusColor(record.status)}`}>
|
||||
{record.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="text-xs text-gray-500">
|
||||
正确率 {correctRate}% ({record.correctCount}/{record.totalCount})
|
||||
</span>
|
||||
</div>
|
||||
<Button type="primary" onClick={handleBackToHome} className="bg-mars-500 hover:bg-mars-600 border-none px-4 h-7 text-xs">
|
||||
<Button type="primary" onClick={handleBackToHome} className="bg-[#008C8C] hover:bg-[#00796B] border-none px-6 h-8 text-xs rounded-lg shadow-sm">
|
||||
返回答题记录
|
||||
</Button>
|
||||
</div>
|
||||
@@ -278,8 +280,8 @@ const ResultPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Card className="shadow-lg mb-6 rounded-xl" bodyStyle={{ padding: '12px' }}>
|
||||
<h3 className="text-sm font-semibold mb-2 text-gray-800 border-l-4 border-mars-500 pl-3">答题信息</h3>
|
||||
<Card className="shadow-sm mb-6 rounded-2xl" bodyStyle={{ padding: '20px' }}>
|
||||
<h3 className="text-base font-bold mb-4 text-gray-900 border-l-4 border-[#008C8C] pl-3">答题信息</h3>
|
||||
<Descriptions bordered column={1} size="small" className="text-xs">
|
||||
<Item label="答题时间">{formatDateTime(record.createdAt)}</Item>
|
||||
<Item label="总题数">{record.totalCount} 题</Item>
|
||||
@@ -291,9 +293,9 @@ const ResultPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* 答案详情 */}
|
||||
<Card className="shadow-lg rounded-xl">
|
||||
<h3 className="text-base font-semibold mb-3 text-gray-800 border-l-4 border-mars-500 pl-3">答案详情</h3>
|
||||
<div className="space-y-3">
|
||||
<Card className="shadow-sm rounded-2xl">
|
||||
<h3 className="text-base font-bold mb-4 text-gray-900 border-l-4 border-[#008C8C] pl-3">答案详情</h3>
|
||||
<div className="space-y-4">
|
||||
{answers.map((answer, index) => (
|
||||
<div
|
||||
key={answer.id}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Row, Col, Statistic, Button, Table, message, DatePicker, Select, Space } from 'antd';
|
||||
import { Row, Col, Statistic, Button, Table, App, DatePicker, Select, Space, Tag } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
DatabaseOutlined,
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
ReloadOutlined,
|
||||
ArrowRightOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { adminAPI, quizAPI } from '../../services/api';
|
||||
import { formatDateTime, parseUtcDateTime } from '../../utils/validation';
|
||||
@@ -18,8 +19,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
Legend,
|
||||
Label,
|
||||
} from 'recharts';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface DashboardOverview {
|
||||
totalUsers: number;
|
||||
@@ -65,6 +66,7 @@ interface TaskStatRow extends ActiveTaskStat {
|
||||
|
||||
const AdminDashboardPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
|
||||
const [taskStats, setTaskStats] = useState<TaskStatRow[]>([]);
|
||||
@@ -101,405 +103,231 @@ const AdminDashboardPage = () => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [overviewResponse, recordsResponse, taskStatsResponse] =
|
||||
await Promise.all([
|
||||
adminAPI.getDashboardOverview(),
|
||||
fetchRecentRecords(),
|
||||
adminAPI.getAllTaskStats(buildTaskStatsParams(1, taskStatusFilter, endAtRange)),
|
||||
]);
|
||||
const [overviewRes, recentRecordsRes, taskStatsRes] = await Promise.all([
|
||||
adminAPI.getDashboardOverview(),
|
||||
quizAPI.getAllRecords({ page: 1, limit: 10 }),
|
||||
adminAPI.getAllTaskStats(buildTaskStatsParams(1, taskStatusFilter, endAtRange)),
|
||||
]);
|
||||
|
||||
setOverview(overviewResponse.data);
|
||||
setRecentRecords(recordsResponse);
|
||||
setTaskStats((taskStatsResponse as any).data || []);
|
||||
if ((taskStatsResponse as any).pagination) {
|
||||
if (overviewRes.data) setOverview(overviewRes.data);
|
||||
if (Array.isArray((recentRecordsRes as any).data)) setRecentRecords((recentRecordsRes as any).data);
|
||||
if (taskStatsRes.data) {
|
||||
setTaskStats(taskStatsRes.data.list);
|
||||
setTaskStatsPagination({
|
||||
page: (taskStatsResponse as any).pagination.page,
|
||||
limit: (taskStatsResponse as any).pagination.limit,
|
||||
total: (taskStatsResponse as any).pagination.total,
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: taskStatsRes.data.total,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取数据失败');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
message.error('获取仪表盘数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskStats = async (
|
||||
page: number,
|
||||
next: { status?: '' | 'completed' | 'ongoing' | 'notStarted'; range?: [Dayjs | null, Dayjs | null] | null } = {},
|
||||
) => {
|
||||
const fetchTaskStats = async (page: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const status = next.status ?? taskStatusFilter;
|
||||
const range = next.range ?? endAtRange;
|
||||
const response = (await adminAPI.getAllTaskStats(buildTaskStatsParams(page, status, range))) as any;
|
||||
setTaskStats(response.data || []);
|
||||
if (response.pagination) {
|
||||
setTaskStatsPagination({
|
||||
page: response.pagination.page,
|
||||
limit: response.pagination.limit,
|
||||
total: response.pagination.total,
|
||||
});
|
||||
const res = await adminAPI.getAllTaskStats(
|
||||
buildTaskStatsParams(page, taskStatusFilter, endAtRange)
|
||||
);
|
||||
if (res.data) {
|
||||
setTaskStats(res.data.list);
|
||||
setTaskStatsPagination(prev => ({ ...prev, page, total: res.data.total }));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取考试任务统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task stats:', error);
|
||||
message.error('获取任务统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecentRecords = async () => {
|
||||
const response = await quizAPI.getAllRecords({ page: 1, limit: 10 }) as any;
|
||||
return response.data || [];
|
||||
const handleTaskFilterChange = () => {
|
||||
fetchTaskStats(1);
|
||||
};
|
||||
|
||||
const totalQuestions =
|
||||
overview?.questionCategoryStats?.reduce((sum, item) => sum + (Number(item.count) || 0), 0) || 0;
|
||||
useEffect(() => {
|
||||
handleTaskFilterChange();
|
||||
}, [taskStatusFilter, endAtRange]);
|
||||
|
||||
const totalTasks = overview
|
||||
? Number(overview.taskStatusDistribution.completed || 0) +
|
||||
Number(overview.taskStatusDistribution.ongoing || 0) +
|
||||
Number(overview.taskStatusDistribution.notStarted || 0)
|
||||
: 0;
|
||||
const COLORS = ['#22D3EE', '#F472B6', '#A78BFA', '#34D399', '#FBBF24', '#60A5FA'];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case '不及格':
|
||||
return 'red';
|
||||
case '合格':
|
||||
return 'blue';
|
||||
case '优秀':
|
||||
return 'green';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const recordColumns = [
|
||||
{
|
||||
title: '姓名',
|
||||
title: '用户',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
render: (text: string, record: RecentRecord) => (
|
||||
<Space>
|
||||
<div className="w-8 h-8 rounded-full bg-cyan-500/20 flex items-center justify-center text-cyan-400 font-bold">
|
||||
{text.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white/90">{text}</div>
|
||||
<div className="text-xs text-white/50">{record.userPhone}</div>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'userPhone',
|
||||
key: 'userPhone',
|
||||
title: '考试科目',
|
||||
dataIndex: 'subjectName',
|
||||
key: 'subjectName',
|
||||
render: (text: string) => <Tag color="blue" className="bg-blue-500/20 border-blue-500/30 text-blue-300">{text || '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
render: (score: number) => <span className="font-semibold text-mars-600">{score} 分</span>,
|
||||
key: 'score',
|
||||
render: (_: any, record: RecentRecord) => (
|
||||
<span className="font-bold text-emerald-400">
|
||||
{record.totalScore} <span className="text-xs text-white/40 font-normal">/ {record.totalCount}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const color = getStatusColor(status);
|
||||
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-100 text-${color}-800`}>{status}</span>;
|
||||
let color = 'default';
|
||||
let bg = 'rgba(255,255,255,0.1)';
|
||||
if (status === '优秀') { color = '#34D399'; bg = 'rgba(52, 211, 153, 0.1)'; }
|
||||
if (status === '合格') { color = '#22D3EE'; bg = 'rgba(34, 211, 238, 0.1)'; }
|
||||
if (status === '不及格') { color = '#F87171'; bg = 'rgba(248, 113, 113, 0.1)'; }
|
||||
return (
|
||||
<span style={{ color, background: bg, padding: '4px 8px', borderRadius: '4px', fontSize: '12px' }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
key: 'correctRate',
|
||||
render: (_: any, record: RecentRecord) => {
|
||||
const rate = record.totalCount > 0
|
||||
? ((record.correctCount / record.totalCount) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
return <span>{rate}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '考试科目',
|
||||
dataIndex: 'subjectName',
|
||||
key: 'subjectName',
|
||||
render: (subjectName?: string) => subjectName || '',
|
||||
},
|
||||
{
|
||||
title: '考试人数',
|
||||
dataIndex: 'examCount',
|
||||
key: 'examCount',
|
||||
render: (examCount?: number) => examCount || '',
|
||||
},
|
||||
{
|
||||
title: '答题时间',
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => formatDateTime(date),
|
||||
},
|
||||
];
|
||||
|
||||
const taskStatsColumns = [
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'taskName',
|
||||
key: 'taskName',
|
||||
},
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'subjectName',
|
||||
key: 'subjectName',
|
||||
},
|
||||
{
|
||||
title: '指定考试人数',
|
||||
dataIndex: 'totalUsers',
|
||||
key: 'totalUsers',
|
||||
},
|
||||
{
|
||||
title: '考试进度',
|
||||
key: 'progress',
|
||||
render: (_: any, record: ActiveTaskStat) => {
|
||||
const now = new Date();
|
||||
const start = parseUtcDateTime(record.startAt) ?? new Date(record.startAt);
|
||||
const end = parseUtcDateTime(record.endAt) ?? new Date(record.endAt);
|
||||
|
||||
const totalDuration = end.getTime() - start.getTime();
|
||||
const elapsedDuration = now.getTime() - start.getTime();
|
||||
const progress =
|
||||
totalDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100)))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-mars-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="font-semibold text-mars-600">{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '考试人数统计',
|
||||
key: 'statistics',
|
||||
render: (_: any, record: ActiveTaskStat) => {
|
||||
const total = record.totalUsers;
|
||||
const completed = record.completedUsers;
|
||||
|
||||
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: excellent, color: '#008C8C' },
|
||||
{ name: '合格', value: passedOnly, color: '#00A3A3' },
|
||||
{ name: '不及格', value: failed, color: '#ff4d4f' },
|
||||
{ name: '未完成', value: incomplete, color: '#f0f0f0' },
|
||||
];
|
||||
|
||||
const filteredData = pieData.filter((item) => item.value > 0);
|
||||
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-20">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filteredData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={35}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{filteredData.map((entry, index) => (
|
||||
<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: 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: string, entry: any) => (
|
||||
<span className="text-xs text-gray-600 ml-1">
|
||||
{value} {entry.payload.value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
render: (text: string) => <span className="text-white/60 text-xs">{formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">管理仪表盘</h1>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">仪表盘</h2>
|
||||
<p className="text-white/50 text-sm">系统运行状态概览</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchDashboardData}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchDashboardData}
|
||||
loading={loading}
|
||||
className="bg-mars-500 hover:bg-mars-600"
|
||||
type="primary"
|
||||
ghost
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/users')}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
{/* Top Stats Cards */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<GlassCard className="hover:scale-[1.02] transition-transform duration-300">
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
title={<span className="text-white/60">总用户数</span>}
|
||||
value={overview?.totalUsers || 0}
|
||||
prefix={<TeamOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
prefix={<TeamOutlined className="text-cyan-400 mr-2" />}
|
||||
valueStyle={{ color: '#fff', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/question-bank')}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<GlassCard className="hover:scale-[1.02] transition-transform duration-300">
|
||||
<Statistic
|
||||
title="题库统计"
|
||||
value={totalQuestions}
|
||||
prefix={<DatabaseOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
suffix="题"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/subjects')}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<Statistic
|
||||
title="考试科目"
|
||||
title={<span className="text-white/60">活跃科目</span>}
|
||||
value={overview?.activeSubjectCount || 0}
|
||||
prefix={<BookOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
suffix="个"
|
||||
prefix={<BookOutlined className="text-purple-400 mr-2" />}
|
||||
valueStyle={{ color: '#fff', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/exam-tasks')}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<GlassCard className="hover:scale-[1.02] transition-transform duration-300">
|
||||
<Statistic
|
||||
title="考试任务"
|
||||
value={totalTasks}
|
||||
prefix={<CalendarOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
suffix="个"
|
||||
title={<span className="text-white/60">进行中任务</span>}
|
||||
value={overview?.taskStatusDistribution?.ongoing || 0}
|
||||
prefix={<CalendarOutlined className="text-emerald-400 mr-2" />}
|
||||
valueStyle={{ color: '#fff', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<GlassCard className="hover:scale-[1.02] transition-transform duration-300">
|
||||
<Statistic
|
||||
title={<span className="text-white/60">题库总数</span>}
|
||||
value={overview?.questionCategoryStats?.reduce((acc, curr) => acc + curr.count, 0) || 0}
|
||||
prefix={<DatabaseOutlined className="text-rose-400 mr-2" />}
|
||||
valueStyle={{ color: '#fff', fontWeight: 'bold' }}
|
||||
/>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="考试任务统计"
|
||||
className="mb-8 shadow-sm"
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={taskStatusFilter}
|
||||
onChange={(value) => {
|
||||
setTaskStatusFilter(value);
|
||||
fetchTaskStats(1, { status: value });
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'ongoing', label: '进行中' },
|
||||
{ value: 'notStarted', label: '未开始' },
|
||||
]}
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* Recent Activity */}
|
||||
<Col xs={24} lg={16}>
|
||||
<GlassCard
|
||||
title={<span className="text-white font-bold text-lg">最近考试记录</span>}
|
||||
extra={<Button type="link" onClick={() => navigate('/admin/statistics')} className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1">查看全部 <ArrowRightOutlined /></Button>}
|
||||
className="h-full"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Table
|
||||
dataSource={recentRecords}
|
||||
columns={recordColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
className="bg-transparent"
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={endAtRange}
|
||||
placeholder={['结束时间开始', '结束时间结束']}
|
||||
format="YYYY-MM-DD"
|
||||
onChange={(value) => {
|
||||
setEndAtRange(value);
|
||||
fetchTaskStats(1, { range: value });
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={taskStatsColumns}
|
||||
dataSource={taskStats}
|
||||
rowKey="taskId"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: taskStatsPagination.page,
|
||||
pageSize: 5,
|
||||
total: taskStatsPagination.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => fetchTaskStats(page),
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
|
||||
{/* 最近答题记录 */}
|
||||
<Card title="最近答题记录" className="shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={recentRecords}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
{/* Distribution Chart */}
|
||||
<Col xs={24} lg={8}>
|
||||
<GlassCard title={<span className="text-white font-bold text-lg">题目分类分布</span>} className="h-full">
|
||||
<div style={{ width: '100%', height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={overview?.questionCategoryStats || []}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="count"
|
||||
nameKey="category"
|
||||
>
|
||||
{(overview?.questionCategoryStats || []).map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} stroke="rgba(0,0,0,0.2)" />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(30, 41, 59, 0.9)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#fff'
|
||||
}}
|
||||
/>
|
||||
<Legend verticalAlign="bottom" height={36} formatter={(value) => <span style={{ color: 'rgba(255,255,255,0.7)' }}>{value}</span>} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, Input, Button, App, Select, Layout } from 'antd';
|
||||
import { Card, Form, Input, Button, App, Select, Layout, ConfigProvider, theme } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAdmin } from '../../contexts';
|
||||
import { adminAPI } from '../../services/api';
|
||||
import 主要LOGO from '../../assets/主要LOGO.svg';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
|
||||
@@ -94,115 +95,133 @@ const AdminLoginPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout className="min-h-screen bg-gray-50">
|
||||
<Header className="bg-white shadow-sm flex items-center px-4 h-12 sticky top-0 z-10">
|
||||
<img src={主要LOGO} alt="主要LOGO" style={{ width: '120px', height: '48px' }} />
|
||||
</Header>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#22D3EE',
|
||||
colorBgContainer: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Layout className="min-h-screen bg-[#0A1628]">
|
||||
<Header className="bg-transparent border-b border-white/10 flex items-center px-4 h-16 sticky top-0 z-10 backdrop-blur-md">
|
||||
<img src={主要LOGO} alt="主要LOGO" style={{ width: '120px', height: '48px', filter: 'brightness(0) invert(1)' }} />
|
||||
</Header>
|
||||
|
||||
<Content className="flex items-center justify-center p-2 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 p-2">
|
||||
<div className="text-center mb-3">
|
||||
<h1 className="text-2xl font-bold text-mars-600 mb-2">管理员登录</h1>
|
||||
<p className="text-gray-600 text-xs">请输入管理员账号密码</p>
|
||||
</div>
|
||||
<Content className="flex items-center justify-center p-4 bg-gradient-to-br from-[#0A1628] via-[#0f2942] to-[#0A1628] relative overflow-hidden">
|
||||
{/* Decorative ambient gradients */}
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
>
|
||||
{/* 最近登录记录下拉选择 */}
|
||||
{loginRecords.length > 0 && (
|
||||
<Form.Item label="最近登录" className="mb-2">
|
||||
<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 text-xs">{record.username}</span>
|
||||
<span className="text-xs text-gray-500 block">
|
||||
{new Date(record.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
<GlassCard className="p-4">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">管理员登录</h1>
|
||||
<p className="text-white/60 text-xs">请输入管理员账号密码</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
size="large"
|
||||
>
|
||||
{/* 最近登录记录下拉选择 */}
|
||||
{loginRecords.length > 0 && (
|
||||
<Form.Item label={<span className="text-white/80">最近登录</span>} className="mb-4">
|
||||
<Select
|
||||
placeholder="选择最近登录记录"
|
||||
className="w-full"
|
||||
style={{ height: 'auto' }}
|
||||
dropdownStyle={{ backgroundColor: 'rgba(30, 41, 59, 0.95)', border: '1px solid rgba(255,255,255,0.1)' }}
|
||||
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">
|
||||
<span className="font-medium block text-sm text-white">{record.username}</span>
|
||||
<span className="text-xs text-white/50 block">
|
||||
{new Date(record.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-white/80">用户名</span>}
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
className="mb-4"
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/30 hover:bg-white/10 focus:bg-white/10 focus:border-cyan-500/50"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
className="mb-2"
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username" // 正确的自动完成属性
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
className="mb-2"
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入密码"
|
||||
autoComplete="new-password" // 防止自动填充密码
|
||||
allowClear // 允许清空密码
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0 mt-3">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="bg-mars-500 hover:!bg-mars-600 border-none shadow-md h-9 text-sm font-medium"
|
||||
<Form.Item
|
||||
label={<span className="text-white/80">密码</span>}
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
className="mb-6"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Input.Password
|
||||
placeholder="请输入密码"
|
||||
autoComplete="new-password"
|
||||
allowClear
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/30 hover:bg-white/10 focus:bg-white/10 focus:border-cyan-500/50"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-mars-600 hover:text-mars-800 text-xs transition-colors"
|
||||
>
|
||||
返回用户端
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Content>
|
||||
<Form.Item className="mb-0">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="bg-cyan-600 hover:!bg-cyan-500 border-none shadow-lg shadow-cyan-900/20 h-10 font-medium"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Footer className="bg-white border-t border-gray-100 py-1.5 px-2 flex flex-col md:flex-row justify-center items-center text-gray-400 text-xs">
|
||||
<div className="mb-2 md:mb-0 whitespace-nowrap">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
</Footer>
|
||||
</Layout>
|
||||
<div className="mt-4 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-cyan-400 hover:text-cyan-300 text-xs transition-colors"
|
||||
>
|
||||
返回用户端
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-[#0A1628] border-t border-white/5 py-4 flex flex-col md:flex-row justify-center items-center text-white/30 text-xs">
|
||||
<div className="mb-2 md:mb-0 whitespace-nowrap">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Table, Button, message, Upload, Modal } from 'antd';
|
||||
import { Card, Table, Button, App, Upload, Modal } from 'antd';
|
||||
import { UploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import * as XLSX from 'xlsx';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
const BackupRestorePage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 数据备份
|
||||
@@ -154,14 +156,14 @@ const BackupRestorePage = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">数据备份与恢复</h1>
|
||||
<h1 className="text-2xl font-bold text-white">数据备份与恢复</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 数据备份 */}
|
||||
<Card title="数据备份" className="shadow-sm">
|
||||
<GlassCard title={<span className="text-white font-bold text-lg">数据备份</span>}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
<p className="text-white/70">
|
||||
备份所有数据到Excel文件,包括用户信息、题库、答题记录等。
|
||||
</p>
|
||||
<Button
|
||||
@@ -175,12 +177,12 @@ const BackupRestorePage = () => {
|
||||
立即备份
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* 数据恢复 */}
|
||||
<Card title="数据恢复" className="shadow-sm">
|
||||
<GlassCard title={<span className="text-white font-bold text-lg">数据恢复</span>}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
<p className="text-white/70">
|
||||
从Excel文件恢复数据,将覆盖现有数据,请谨慎操作。
|
||||
</p>
|
||||
<Upload
|
||||
@@ -199,7 +201,7 @@ const BackupRestorePage = () => {
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* 注意事项 */}
|
||||
@@ -216,4 +218,4 @@ const BackupRestorePage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupRestorePage;
|
||||
export default BackupRestorePage;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-de
|
||||
import api from '../../services/api';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
import { parseUtcDateTime } from '../../utils/validation';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
@@ -344,8 +345,8 @@ const ExamSubjectPage = () => {
|
||||
const ratioMode = isRatioMode(ratios || {});
|
||||
const total = sumValues(ratios || {});
|
||||
return (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
<div className="p-3 bg-white/5 rounded-lg border border-white/10 shadow-sm">
|
||||
<div className="w-full bg-white/10 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{ratios && Object.entries(ratios).map(([type, value]) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type);
|
||||
const widthPercent = ratioMode ? value : (total > 0 ? (value / total) * 100 : 0);
|
||||
@@ -371,8 +372,8 @@ const ExamSubjectPage = () => {
|
||||
className="inline-block w-3 h-3 mr-2 rounded-full"
|
||||
style={{ backgroundColor: typeConfig?.color || '#1890ff' }}
|
||||
></span>
|
||||
<span className="flex-1">{typeConfig?.label || type}</span>
|
||||
<span className="font-medium">{ratioMode ? `${value}%` : `${value}题(${percent}%)`}</span>
|
||||
<span className="flex-1 text-white/70">{typeConfig?.label || type}</span>
|
||||
<span className="font-medium text-white/90">{ratioMode ? `${value}%` : `${value}题(${percent}%)`}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -392,8 +393,8 @@ const ExamSubjectPage = () => {
|
||||
: [];
|
||||
const total = entries.reduce((s, [, v]) => s + (Number(v) || 0), 0);
|
||||
return (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
<div className="p-3 bg-white/5 rounded-lg border border-white/10 shadow-sm">
|
||||
<div className="w-full bg-white/10 rounded-full h-4 flex mb-3 overflow-hidden">
|
||||
{entries.map(([category, value]) => (
|
||||
<div
|
||||
key={category}
|
||||
@@ -414,8 +415,8 @@ const ExamSubjectPage = () => {
|
||||
className="inline-block w-3 h-3 mr-2 rounded-full"
|
||||
style={{ backgroundColor: getCategoryColorHex(category) }}
|
||||
></span>
|
||||
<span className="flex-1">{category}</span>
|
||||
<span className="font-medium">{ratioMode ? `${value}%` : `${value}题(${percent}%)`}</span>
|
||||
<span className="flex-1 text-white/70">{category}</span>
|
||||
<span className="font-medium text-white/90">{ratioMode ? `${value}%` : `${value}题(${percent}%)`}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -433,7 +434,7 @@ const ExamSubjectPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<div>{date.toLocaleDateString()}</div>
|
||||
<div className="text-sm text-gray-500">{date.toLocaleTimeString()}</div>
|
||||
<div className="text-sm text-white/40">{date.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -476,9 +477,9 @@ const ExamSubjectPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">考试科目管理</h1>
|
||||
<h1 className="text-2xl font-bold text-white">考试科目管理</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增科目
|
||||
</Button>
|
||||
@@ -753,19 +754,19 @@ const ExamSubjectPage = () => {
|
||||
|
||||
<div className="mt-3">
|
||||
<Tag color="blue">解析</Tag>
|
||||
<span className="text-gray-700 whitespace-pre-wrap">{question.analysis || ''}</span>
|
||||
<span className="text-gray-300 whitespace-pre-wrap">{question.analysis || ''}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<div className="text-center py-10 text-gray-400">
|
||||
暂无考题数据
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select, Tabs, Tag } from 'antd';
|
||||
import { Table, Button, Input, Space, App, Popconfirm, Modal, Form, DatePicker, Select, Tabs, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatDateTime, parseUtcDateTime } from '../../utils/validation';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface ExamTask {
|
||||
id: string;
|
||||
@@ -38,6 +39,7 @@ interface UserGroup {
|
||||
}
|
||||
|
||||
const ExamTaskPage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [tasks, setTasks] = useState<ExamTask[]>([]);
|
||||
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -264,9 +266,9 @@ const ExamTaskPage = () => {
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="w-full bg-white/10 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
@@ -347,15 +349,16 @@ const ExamTaskPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">考试任务管理</h1>
|
||||
<h1 className="text-2xl font-bold text-white">考试任务管理</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
className="bg-transparent"
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
@@ -431,7 +434,7 @@ const ExamTaskPage = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded mb-4">
|
||||
<div className="bg-white/5 p-4 rounded mb-4">
|
||||
<h4 className="mb-2 font-medium">任务分配对象</h4>
|
||||
|
||||
<Form.Item
|
||||
@@ -487,8 +490,8 @@ const ExamTaskPage = () => {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mt-2 text-right text-gray-500">
|
||||
实际分配人数(去重后):<span className="font-bold text-blue-600">{uniqueUserCount}</span> 人
|
||||
<div className="mt-2 text-right text-gray-400">
|
||||
实际分配人数(去重后):<span className="font-bold text-blue-400">{uniqueUserCount}</span> 人
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
@@ -548,7 +551,7 @@ const ExamTaskPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag, Tooltip } from 'antd';
|
||||
import { Table, Button, Input, Space, App, Popconfirm, Modal, Form, Tag, Tooltip } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface QuestionCategory {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ interface QuestionCategory {
|
||||
}
|
||||
|
||||
const QuestionCategoryPage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [categories, setCategories] = useState<QuestionCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -152,9 +154,9 @@ const QuestionCategoryPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">题目类别管理</h1>
|
||||
<h1 className="text-2xl font-bold text-white">题目类别管理</h1>
|
||||
<Space>
|
||||
<Button icon={<EditOutlined spin={loading} />} onClick={handleRefresh}>
|
||||
刷新类别
|
||||
@@ -194,7 +196,7 @@ const QuestionCategoryPage = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Input,
|
||||
Select,
|
||||
Upload,
|
||||
message,
|
||||
App,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Row,
|
||||
@@ -35,6 +35,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { questionAPI } from '../../services/api';
|
||||
import { formatDate, questionTypeMap, questionTypeColors } from '../../utils/validation';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
@@ -52,6 +53,7 @@ interface Question {
|
||||
|
||||
const QuestionManagePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -477,9 +479,9 @@ const QuestionManagePage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">题库管理</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-4">题库管理</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
{/* 题型筛选 - 动态生成选项 */}
|
||||
@@ -697,7 +699,7 @@ const QuestionManagePage = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Card, Input, Modal, Radio, Space, Statistic, Table, Tag, Typography, message } from 'antd';
|
||||
import { Alert, Button, Card, Input, Modal, Radio, Space, Statistic, Table, Tag, Typography, App } from 'antd';
|
||||
import { ArrowLeftOutlined, DeleteOutlined, FileTextOutlined, ImportOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { questionAPI } from '../../services/api';
|
||||
import { questionTypeMap } from '../../utils/validation';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
import { type ImportMode, type ImportQuestion, parseTextQuestions } from '../../utils/questionTextImport';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const QuestionTextImportPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [rawText, setRawText] = useState('');
|
||||
const [mode, setMode] = useState<ImportMode>('incremental');
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
@@ -154,9 +156,9 @@ const QuestionTextImportPage = () => {
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/questions')}>
|
||||
返回
|
||||
</Button>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
<h1 className="text-2xl font-bold text-white mb-0">
|
||||
文本导入题库
|
||||
</Typography.Title>
|
||||
</h1>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
@@ -176,7 +178,7 @@ const QuestionTextImportPage = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm mb-4">
|
||||
<GlassCard className="mb-4">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
@@ -188,10 +190,10 @@ const QuestionTextImportPage = () => {
|
||||
</Button>
|
||||
</Space>
|
||||
<Space size="large">
|
||||
<Statistic title="本次导入题目总数" value={totalCount} valueStyle={{ color: '#1677ff', fontWeight: 700 }} />
|
||||
<Statistic title="有效题目数量" value={validCount} valueStyle={{ color: '#52c41a', fontWeight: 700 }} />
|
||||
<Statistic title={<span className="text-white/70">本次导入题目总数</span>} value={totalCount} valueStyle={{ color: '#1677ff', fontWeight: 700 }} />
|
||||
<Statistic title={<span className="text-white/70">有效题目数量</span>} value={validCount} valueStyle={{ color: '#52c41a', fontWeight: 700 }} />
|
||||
<Statistic
|
||||
title="无效题目数量"
|
||||
title={<span className="text-white/70">无效题目数量</span>}
|
||||
value={invalidCount}
|
||||
valueStyle={{ color: invalidCount > 0 ? '#ff4d4f' : '#8c8c8c', fontWeight: 700 }}
|
||||
/>
|
||||
@@ -203,6 +205,7 @@ const QuestionTextImportPage = () => {
|
||||
onChange={(e) => setRawText(e.target.value)}
|
||||
placeholder={exampleText}
|
||||
rows={12}
|
||||
className="bg-white/90"
|
||||
/>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
@@ -220,9 +223,9 @@ const QuestionTextImportPage = () => {
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<GlassCard>
|
||||
<Table
|
||||
columns={columns as any}
|
||||
dataSource={questions.map((q, idx) => ({ ...q, key: `${q.content}-${idx}` }))}
|
||||
@@ -238,7 +241,7 @@ const QuestionTextImportPage = () => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Form, InputNumber, Button, Row, Col, Progress, message } from 'antd';
|
||||
import { Form, InputNumber, Button, Row, Col, Progress, App } from 'antd';
|
||||
import { adminAPI } from '../../services/api';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface QuizConfig {
|
||||
singleRatio: number;
|
||||
@@ -12,6 +13,7 @@ interface QuizConfig {
|
||||
|
||||
const QuizConfigPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// 使用state来跟踪实时配置值
|
||||
@@ -82,24 +84,20 @@ const QuizConfigPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">抽题配置</h1>
|
||||
<p className="text-gray-600 mt-2">设置各题型的比例和试卷总分</p>
|
||||
<h1 className="text-2xl font-bold text-white">抽题配置</h1>
|
||||
<p className="text-white/60 mt-2">设置各题型的比例和试卷总分</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
className="shadow-sm"
|
||||
loading={loading}
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>抽题配置</span>
|
||||
<span className={`text-sm ${configValues.singleRatio + configValues.multipleRatio + configValues.judgmentRatio + configValues.textRatio === 100 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
比例总和:{configValues.singleRatio + configValues.multipleRatio + configValues.judgmentRatio + configValues.textRatio}%
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="bg-white/5 p-6 rounded-lg mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-lg font-medium text-white">抽题配置</span>
|
||||
<span className={`text-sm ${configValues.singleRatio + configValues.multipleRatio + configValues.judgmentRatio + configValues.textRatio === 100 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
比例总和:{configValues.singleRatio + configValues.multipleRatio + configValues.judgmentRatio + configValues.textRatio}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -116,7 +114,7 @@ const QuizConfigPage = () => {
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="单选题比例 (%)"
|
||||
label={<span className="text-white">单选题比例 (%)</span>}
|
||||
name="singleRatio"
|
||||
rules={[{ required: true, message: '请输入单选题比例' }]}
|
||||
>
|
||||
@@ -130,18 +128,21 @@ const QuizConfigPage = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={configValues.singleRatio}
|
||||
strokeColor={getProgressColor(configValues.singleRatio)}
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Progress
|
||||
percent={configValues.singleRatio}
|
||||
strokeColor={getProgressColor(configValues.singleRatio)}
|
||||
showInfo={false}
|
||||
trailColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="多选题比例 (%)"
|
||||
label={<span className="text-white">多选题比例 (%)</span>}
|
||||
name="multipleRatio"
|
||||
rules={[{ required: true, message: '请输入多选题比例' }]}
|
||||
>
|
||||
@@ -155,18 +156,21 @@ const QuizConfigPage = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={configValues.multipleRatio}
|
||||
strokeColor={getProgressColor(configValues.multipleRatio)}
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Progress
|
||||
percent={configValues.multipleRatio}
|
||||
strokeColor={getProgressColor(configValues.multipleRatio)}
|
||||
showInfo={false}
|
||||
trailColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="判断题比例 (%)"
|
||||
label={<span className="text-white">判断题比例 (%)</span>}
|
||||
name="judgmentRatio"
|
||||
rules={[{ required: true, message: '请输入判断题比例' }]}
|
||||
>
|
||||
@@ -180,18 +184,21 @@ const QuizConfigPage = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={configValues.judgmentRatio}
|
||||
strokeColor={getProgressColor(configValues.judgmentRatio)}
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Progress
|
||||
percent={configValues.judgmentRatio}
|
||||
strokeColor={getProgressColor(configValues.judgmentRatio)}
|
||||
showInfo={false}
|
||||
trailColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="文字题比例 (%)"
|
||||
label={<span className="text-white">文字题比例 (%)</span>}
|
||||
name="textRatio"
|
||||
rules={[{ required: true, message: '请输入文字题比例' }]}
|
||||
>
|
||||
@@ -205,18 +212,21 @@ const QuizConfigPage = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Progress
|
||||
percent={configValues.textRatio}
|
||||
strokeColor={getProgressColor(configValues.textRatio)}
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Progress
|
||||
percent={configValues.textRatio}
|
||||
strokeColor={getProgressColor(configValues.textRatio)}
|
||||
showInfo={false}
|
||||
trailColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="试卷总分"
|
||||
label={<span className="text-white">试卷总分</span>}
|
||||
name="totalScore"
|
||||
rules={[{ required: true, message: '请输入试卷总分' }]}
|
||||
>
|
||||
@@ -230,7 +240,7 @@ const QuizConfigPage = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div className="h-8 flex items-center text-gray-600">
|
||||
<div className="h-full flex items-center text-white/50 pt-4">
|
||||
建议设置:100-150分
|
||||
</div>
|
||||
</Col>
|
||||
@@ -247,19 +257,20 @@ const QuizConfigPage = () => {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 配置说明 */}
|
||||
<Card title="配置说明" className="mt-6 shadow-sm">
|
||||
<div className="space-y-3 text-gray-600">
|
||||
<div className="bg-white/5 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-white mb-4">配置说明</h3>
|
||||
<div className="space-y-3 text-white/60">
|
||||
<p>• 各题型比例总和必须为100%</p>
|
||||
<p>• 系统会根据比例和总分自动计算各题型的题目数量</p>
|
||||
<p>• 建议单选题比例不低于30%,确保试卷的覆盖面</p>
|
||||
<p>• 文字题比例建议不超过20%,避免评分主观性过强</p>
|
||||
<p>• 总分设置建议为100分,便于成绩统计和分析</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
|
||||
import { Table, Card, Row, Col, Statistic, Button, App } from 'antd';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../../services/api';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface Answer {
|
||||
id: string;
|
||||
@@ -45,6 +46,7 @@ const getStatusColor = (status: string) => {
|
||||
};
|
||||
|
||||
const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
const { message } = App.useApp();
|
||||
const [record, setRecord] = useState<Record | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -131,7 +133,7 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
key: 'score',
|
||||
width: '5%',
|
||||
render: (score: number, record: Answer) => (
|
||||
<span className={record.isCorrect ? 'text-green-600' : 'text-red-600'}>
|
||||
<span className={record.isCorrect ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
{score} / {record.questionScore}
|
||||
</span>
|
||||
),
|
||||
@@ -142,7 +144,7 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
key: 'isCorrect',
|
||||
width: '5%',
|
||||
render: (isCorrect: boolean) => (
|
||||
<span className={isCorrect ? 'text-green-600' : 'text-red-600'}>
|
||||
<span className={isCorrect ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
{isCorrect ? '✓' : '✗'}
|
||||
</span>
|
||||
),
|
||||
@@ -152,60 +154,64 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">答题记录详情</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
<h1 className="text-2xl font-bold text-white">答题记录详情</h1>
|
||||
<p className="text-white/60 mt-2">
|
||||
答题时间:{formatDateTime(record.createdAt, { includeSeconds: true })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} className="mb-6">
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic title="考试状态" value={record.status} valueStyle={{ color: getStatusColor(record.status) === 'red' ? '#f5222d' : getStatusColor(record.status) === 'green' ? '#52c41a' : '#1890ff' }} />
|
||||
</Card>
|
||||
<Row gutter={[16, 16]} className="mb-6">
|
||||
<Col xs={24} sm={12} md={5}>
|
||||
<GlassCard>
|
||||
<Statistic title={<span className="text-white/70">考试状态</span>} value={record.status} valueStyle={{ color: getStatusColor(record.status) === 'red' ? '#F87171' : getStatusColor(record.status) === 'green' ? '#34D399' : '#22D3EE' }} />
|
||||
</GlassCard>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic title="总得分" value={record.totalScore} suffix="分" />
|
||||
</Card>
|
||||
<Col xs={24} sm={12} md={5}>
|
||||
<GlassCard>
|
||||
<Statistic title={<span className="text-white/70">总得分</span>} value={record.totalScore} suffix="分" valueStyle={{ color: '#fff' }} />
|
||||
</GlassCard>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic title="正确题数" value={record.correctCount} />
|
||||
</Card>
|
||||
<Col xs={24} sm={12} md={5}>
|
||||
<GlassCard>
|
||||
<Statistic title={<span className="text-white/70">正确题数</span>} value={record.correctCount} valueStyle={{ color: '#34D399' }} />
|
||||
</GlassCard>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic title="总题数" value={record.totalCount} />
|
||||
</Card>
|
||||
<Col xs={24} sm={12} md={5}>
|
||||
<GlassCard>
|
||||
<Statistic title={<span className="text-white/70">总题数</span>} value={record.totalCount} valueStyle={{ color: '#fff' }} />
|
||||
</GlassCard>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<GlassCard>
|
||||
<Statistic
|
||||
title="正确率"
|
||||
title={<span className="text-white/70">正确率</span>}
|
||||
value={record.totalCount > 0 ? ((record.correctCount / record.totalCount) * 100).toFixed(1) : 0}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#FBBF24' }}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} className="mb-6">
|
||||
<Col span={12}>
|
||||
<Card title="题型正确率">
|
||||
<Row gutter={[16, 16]} className="mb-6">
|
||||
<Col xs={24} lg={12}>
|
||||
<GlassCard title={<span className="text-white font-bold">题型正确率</span>}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={typeChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="type" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis dataKey="type" stroke="rgba(255,255,255,0.5)" />
|
||||
<YAxis stroke="rgba(255,255,255,0.5)" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'rgba(0,0,0,0.8)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
/>
|
||||
<Bar dataKey="正确率" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="答题结果分布">
|
||||
<Col xs={24} lg={12}>
|
||||
<GlassCard title={<span className="text-white font-bold">答题结果分布</span>}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -215,12 +221,16 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
innerRadius={60}
|
||||
outerRadius={120}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'rgba(0,0,0,0.8)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex justify-center mt-4">
|
||||
@@ -230,15 +240,15 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
className="w-4 h-4 rounded mr-2"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span>{item.name}: {item.value}</span>
|
||||
<span className="text-white/80">{item.name}: {item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="答题详情">
|
||||
<GlassCard title={<span className="text-white font-bold">答题详情</span>}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={record.answers}
|
||||
@@ -251,9 +261,9 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordDetailPage;
|
||||
export default RecordDetailPage;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, DatePicker, Button, message, Tabs, Select } from 'antd';
|
||||
import { Row, Col, Statistic, Table, DatePicker, Button, App, Tabs, Select } from 'antd';
|
||||
import { Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api, { adminAPI, quizAPI } from '../../services/api';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { TabPane } = Tabs;
|
||||
@@ -70,6 +71,7 @@ const getStatusColor = (status: string) => {
|
||||
};
|
||||
|
||||
const StatisticsPage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
const [records, setRecords] = useState<QuizRecord[]>([]);
|
||||
const [userStats, setUserStats] = useState<UserStats[]>([]);
|
||||
@@ -212,10 +214,10 @@ const StatisticsPage = () => {
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const color = getStatusColor(status);
|
||||
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-100 text-${color}-800`}>{status}</span>;
|
||||
return <span className={`px-2 py-1 rounded text-xs font-medium bg-${color}-500/20 text-${color}-300`}>{status}</span>;
|
||||
},
|
||||
},
|
||||
{ title: '得分', dataIndex: 'totalScore', key: 'totalScore', render: (score: number) => <span className="font-semibold text-blue-600">{score} 分</span> },
|
||||
{ title: '得分', dataIndex: 'totalScore', key: 'totalScore', render: (score: number) => <span className="font-semibold text-blue-400">{score} 分</span> },
|
||||
{ title: '正确率', key: 'correctRate', render: (record: QuizRecord) => {
|
||||
const rate = ((record.correctCount / record.totalCount) * 100).toFixed(1);
|
||||
return <span>{rate}%</span>;
|
||||
@@ -246,9 +248,9 @@ const StatisticsPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">数据统计分析</h1>
|
||||
<h1 className="text-2xl font-bold text-white">数据统计分析</h1>
|
||||
<div className="space-x-4">
|
||||
<RangePicker onChange={handleDateRangeChange} />
|
||||
<Button type="primary" onClick={exportData}>
|
||||
@@ -260,53 +262,54 @@ const StatisticsPage = () => {
|
||||
{/* 概览统计 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div className="bg-white/5 p-4 rounded-lg">
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
title={<span className="text-white/70">总用户数</span>}
|
||||
value={statistics?.totalUsers || 0}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div className="bg-white/5 p-4 rounded-lg">
|
||||
<Statistic
|
||||
title="总答题数"
|
||||
title={<span className="text-white/70">总答题数</span>}
|
||||
value={statistics?.totalRecords || 0}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div className="bg-white/5 p-4 rounded-lg">
|
||||
<Statistic
|
||||
title="平均分"
|
||||
title={<span className="text-white/70">平均分</span>}
|
||||
value={statistics?.averageScore || 0}
|
||||
precision={1}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
suffix="分"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div className="bg-white/5 p-4 rounded-lg">
|
||||
<Statistic
|
||||
title="活跃率"
|
||||
title={<span className="text-white/70">活跃率</span>}
|
||||
value={statistics?.totalUsers ? ((statistics.totalRecords / statistics.totalUsers) * 100).toFixed(1) : 0}
|
||||
precision={1}
|
||||
valueStyle={{ color: '#f5222d' }}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} className="text-white">
|
||||
<TabPane tab="总体概览" key="overview">
|
||||
{/* 图表 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={24}>
|
||||
<Card title="分数分布" className="shadow-sm">
|
||||
<div className="bg-white/5 p-6 rounded-lg mb-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4">分数分布</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -323,16 +326,20 @@ const StatisticsPage = () => {
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'rgba(0,0,0,0.8)', border: 'none', borderRadius: '8px', color: '#fff' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 详细记录 */}
|
||||
<Card title="答题记录明细" className="shadow-sm">
|
||||
<div className="bg-white/5 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-white mb-4">答题记录明细</h3>
|
||||
<Table
|
||||
className="bg-transparent"
|
||||
columns={overviewColumns}
|
||||
dataSource={records}
|
||||
rowKey="id"
|
||||
@@ -346,12 +353,14 @@ const StatisticsPage = () => {
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="用户统计" key="users">
|
||||
<Card title="用户答题统计" className="shadow-sm">
|
||||
<div className="bg-white/5 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-white mb-4">用户答题统计</h3>
|
||||
<Table
|
||||
className="bg-transparent"
|
||||
columns={userStatsColumns}
|
||||
dataSource={userStats}
|
||||
rowKey="userId"
|
||||
@@ -364,12 +373,14 @@ const StatisticsPage = () => {
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="科目统计" key="subjects">
|
||||
<Card title="科目答题统计" className="shadow-sm">
|
||||
<div className="bg-white/5 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-white mb-4">科目答题统计</h3>
|
||||
<Table
|
||||
className="bg-transparent"
|
||||
columns={subjectStatsColumns}
|
||||
dataSource={subjectStats}
|
||||
rowKey="subjectId"
|
||||
@@ -382,12 +393,14 @@ const StatisticsPage = () => {
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="任务统计" key="tasks">
|
||||
<Card title="考试任务统计" className="shadow-sm">
|
||||
<div className="bg-white/5 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-white mb-4">考试任务统计</h3>
|
||||
<Table
|
||||
className="bg-transparent"
|
||||
columns={taskStatsColumns}
|
||||
dataSource={taskStats}
|
||||
rowKey="taskId"
|
||||
@@ -400,10 +413,10 @@ const StatisticsPage = () => {
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag } from 'antd';
|
||||
import { Table, Button, Input, Space, App, Popconfirm, Modal, Form, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { userGroupAPI } from '../../services/api';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ interface UserGroup {
|
||||
}
|
||||
|
||||
const UserGroupManage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [groups, setGroups] = useState<UserGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -150,8 +152,9 @@ const UserGroupManage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<GlassCard>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">用户组管理</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新增用户组
|
||||
</Button>
|
||||
@@ -193,7 +196,7 @@ const UserGroupManage = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag, Descriptions, Divider, Typography } from 'antd';
|
||||
import { Table, Button, Input, Space, App, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag, Descriptions, Divider, Typography } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import type { UploadProps } from 'antd';
|
||||
import UserGroupManage from './UserGroupManage';
|
||||
import GlassCard from '../../components/common/GlassCard';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -66,6 +67,7 @@ const getStatusColor = (status: string) => {
|
||||
};
|
||||
|
||||
const UserManagePage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -431,7 +433,7 @@ const UserManagePage = () => {
|
||||
};
|
||||
|
||||
const UserListContent = () => (
|
||||
<div>
|
||||
<GlassCard>
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<Input
|
||||
@@ -470,17 +472,18 @@ const UserManagePage = () => {
|
||||
{/* 答题记录面板 */}
|
||||
{selectedUser && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">
|
||||
{selectedUser.name}的答题记录
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="ml-4"
|
||||
className="ml-4 text-white/70 hover:text-white"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</h2>
|
||||
<Table
|
||||
className="bg-transparent"
|
||||
columns={[
|
||||
{
|
||||
title: '考试科目',
|
||||
@@ -711,12 +714,18 @@ const UserManagePage = () => {
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const UserGroupContent = () => (
|
||||
<GlassCard>
|
||||
<UserGroupManage />
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">用户管理</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-4">用户管理</h1>
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
items={[
|
||||
@@ -728,7 +737,7 @@ const UserManagePage = () => {
|
||||
{
|
||||
key: '2',
|
||||
label: '用户组管理',
|
||||
children: <UserGroupManage />,
|
||||
children: <UserGroupContent />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
|
||||
import { Table, Card, Row, Col, Statistic, Button, App } from 'antd';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../../services/api';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
@@ -33,6 +33,7 @@ const getStatusColor = (status: string) => {
|
||||
};
|
||||
|
||||
const UserRecordsPage = ({ userId }: { userId: string }) => {
|
||||
const { message } = App.useApp();
|
||||
const [records, setRecords] = useState<Record[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
|
||||
@@ -36,14 +36,14 @@ export const OptionList = ({ type, options, value, onChange, disabled }: OptionL
|
||||
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="mt-4">
|
||||
<TextArea
|
||||
rows={4}
|
||||
rows={6}
|
||||
value={value as string || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="请输入您的答案..."
|
||||
disabled={disabled}
|
||||
className="rounded-lg border-gray-300 focus:border-[#00897B] focus:ring-[#00897B] text-xs p-2"
|
||||
className="rounded-xl border-gray-300 focus:border-[#008C8C] focus:ring-[#008C8C] text-base p-4 resize-none shadow-sm hover:border-[#008C8C] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,41 +24,44 @@ export const QuizFooter = ({
|
||||
const isLast = current === total - 1;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 px-2 py-2 safe-area-bottom z-30 shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
|
||||
<div className="max-w-md mx-auto flex items-center justify-between">
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white/95 backdrop-blur-sm border-t border-gray-100 px-4 py-2 safe-area-bottom z-30 shadow-[0_-4px_20px_rgba(0,0,0,0.05)]">
|
||||
<div className="max-w-md mx-auto flex items-center justify-between relative h-12">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={onPrev}
|
||||
disabled={isFirst}
|
||||
className={`flex items-center text-gray-600 hover:text-[#00897B] text-sm ${isFirst ? 'opacity-30' : ''}`}
|
||||
className={`flex items-center text-gray-600 hover:text-[#008C8C] hover:bg-transparent px-2 ${isFirst ? 'opacity-30' : ''}`}
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
<div
|
||||
onClick={onOpenSheet}
|
||||
className="flex flex-col items-center justify-center -mt-5 bg-white rounded-full h-14 w-14 shadow-lg border border-gray-100 cursor-pointer active:scale-95 transition-transform"
|
||||
>
|
||||
<AppstoreOutlined className="text-base text-[#00897B] mb-0.5" />
|
||||
<span className="text-[12px] text-gray-500">
|
||||
{answeredCount}/{total}
|
||||
</span>
|
||||
{/* Center Floating Action Button */}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 -mt-4">
|
||||
<div
|
||||
onClick={onOpenSheet}
|
||||
className="flex flex-col items-center justify-center bg-white rounded-2xl h-14 w-14 shadow-[0_4px_12px_rgba(0,140,140,0.2)] border border-[#E0F7FA] cursor-pointer active:scale-95 transition-all duration-200 group"
|
||||
>
|
||||
<AppstoreOutlined className="text-xl text-[#008C8C] mb-0.5 group-hover:scale-110 transition-transform" />
|
||||
<span className="text-[10px] font-medium text-gray-500 group-hover:text-[#008C8C]">
|
||||
{answeredCount}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLast ? (
|
||||
<Button
|
||||
type="text"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
className="flex items-center text-[#00897B] font-medium hover:bg-teal-50 text-sm"
|
||||
className="flex items-center bg-[#008C8C] hover:bg-[#00796B] text-white shadow-sm px-4 rounded-lg h-9"
|
||||
>
|
||||
提交 <RightOutlined />
|
||||
提交 <RightOutlined className="text-xs" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onNext}
|
||||
className="flex items-center text-gray-600 hover:text-[#00897B] text-sm"
|
||||
className="flex items-center text-gray-600 hover:text-[#008C8C] hover:bg-transparent px-2"
|
||||
>
|
||||
下一题 <RightOutlined />
|
||||
</Button>
|
||||
|
||||
@@ -19,20 +19,32 @@ export const QuizHeader = ({ current, total, timeLeft, onGiveUp, taskName }: Qui
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getTimerColor = (seconds: number) => {
|
||||
if (seconds < 300) return 'text-red-600 bg-red-50 border-red-100'; // Less than 5 mins
|
||||
return 'text-[#008C8C] bg-teal-50 border-teal-100';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#00897B] text-white px-2 h-10 flex items-center justify-between shadow-md sticky top-0 z-30">
|
||||
<div className="flex items-center gap-1" onClick={onGiveUp}>
|
||||
<LeftOutlined className="text-sm" />
|
||||
<span className="text-xs">返回</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 bg-[#00796B] px-1.5 py-0.5 rounded-full text-xs">
|
||||
<span>{taskName || '监控中'}</span>
|
||||
<div className="bg-white border-b border-gray-200 px-6 h-16 flex items-center justify-between shadow-sm sticky top-0 z-30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={onGiveUp}
|
||||
className="flex items-center gap-2 cursor-pointer text-gray-600 hover:text-gray-900 transition-colors px-3 py-1.5 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<LeftOutlined />
|
||||
<span className="font-medium">退出考试</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-gray-200 hidden sm:block"></div>
|
||||
<div className="text-lg font-bold text-gray-800 hidden sm:block truncate max-w-md">
|
||||
{taskName || '在线考试'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs font-medium tabular-nums">
|
||||
<ClockCircleOutlined />
|
||||
<span>{timeLeft !== null ? formatTime(timeLeft) : '--:--'}</span>
|
||||
<div className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border ${timeLeft !== null ? getTimerColor(timeLeft) : ''}`}>
|
||||
<ClockCircleOutlined className="text-lg" />
|
||||
<span className="text-xl font-mono font-bold tabular-nums">
|
||||
{timeLeft !== null ? formatTime(timeLeft) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,17 +9,19 @@ export const QuizProgress = ({ current, total }: QuizProgressProps) => {
|
||||
const percent = Math.round((current / total) * 100);
|
||||
|
||||
return (
|
||||
<div className="bg-white px-2 py-1.5 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-xs">
|
||||
<span className="text-gray-900 text-sm font-medium">{current}</span>/{total}
|
||||
</span>
|
||||
<div className="bg-white px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xs text-gray-500 font-medium whitespace-nowrap min-w-[3rem]">
|
||||
<span className="text-[#008C8C] text-sm font-bold">{current}</span>
|
||||
<span className="mx-0.5 text-gray-300">/</span>
|
||||
{total}
|
||||
</div>
|
||||
<Progress
|
||||
percent={percent}
|
||||
showInfo={false}
|
||||
strokeColor="#00897B"
|
||||
trailColor="#E0F2F1"
|
||||
size="small"
|
||||
strokeColor="#008C8C"
|
||||
trailColor="#E0F7FA"
|
||||
size={["100%", 6]}
|
||||
className="m-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user