后台页面尝试用UI UX优化,有点意思~

This commit is contained in:
2026-01-13 23:12:10 +08:00
parent 401e68ec8a
commit eec3ea2238
27 changed files with 1228 additions and 795 deletions

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;
@@ -456,7 +448,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 +457,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 +653,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

@@ -241,7 +241,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 +251,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 +278,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 +291,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>