Compare commits

2 Commits

Author SHA1 Message Date
abfe6e95f9 fix(答案处理): 改进多答案字符串的分割逻辑
处理类似"同创造,共分享,齐飞扬"格式的答案字符串,统一使用逗号分割并去除空格
2026-01-23 18:14:29 +08:00
eec3ea2238 后台页面尝试用UI UX优化,有点意思~ 2026-01-13 23:12:10 +08:00
34 changed files with 1971 additions and 1490 deletions

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

@@ -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",

View File

@@ -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 />} />

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

View File

@@ -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[]>>({});

View File

@@ -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';

View File

@@ -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">
&copy; {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>
);
};

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

View File

@@ -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 (

View File

@@ -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}

View File

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

View File

@@ -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">
&copy; {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">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
</Footer>
</Layout>
</ConfigProvider>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />,
},
]}
/>

View File

@@ -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({

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>