优化UI风格并添加管理员权限系统

主要更改:
- 新增管理员权限系统:添加 AdminAuth 中间件和 AdminRoute 组件,限制题库管理功能仅 yanlongqi 用户可访问
- UI 全面改版为白色毛玻璃风格(macOS 风格):应用毛玻璃效果、优化圆角和阴影、统一配色方案
- 登录页优化:将注册功能改为模态框形式,简化登录界面
- 首页优化:题库管理入口仅对管理员用户显示,优化响应式布局和卡片排列
- 移除底部导航栏:简化布局,改善用户体验

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-05 10:58:43 +08:00
parent 5d1b088e06
commit c0a280132c
15 changed files with 591 additions and 299 deletions

View File

@ -55,3 +55,31 @@ func Auth() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// AdminAuth 管理员认证中间件必须在Auth中间件之后使用
func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取用户名(由 Auth 中间件设置)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查是否为管理员用户
if username != "yanlongqi" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "无权限访问",
})
c.Abort()
return
}
c.Next()
}
}

13
main.go
View File

@ -46,11 +46,6 @@ func main() {
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
// 题库管理API需要认证
auth.POST("/practice/questions", handlers.CreatePracticeQuestion) // 创建题目
auth.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目
auth.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目
// 错题本相关API // 错题本相关API
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表 auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计 auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
@ -59,6 +54,14 @@ func main() {
auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握 auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
} }
// 题库管理API需要管理员权限
admin := api.Group("", middleware.Auth(), middleware.AdminAuth())
{
admin.POST("/practice/questions", handlers.CreatePracticeQuestion) // 创建题目
admin.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目
admin.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目
}
} }
// 静态文件服务(必须在 API 路由之后) // 静态文件服务(必须在 API 路由之后)

View File

@ -2,6 +2,7 @@ import React from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import TabBarLayout from './components/TabBarLayout' import TabBarLayout from './components/TabBarLayout'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
import AdminRoute from './components/AdminRoute'
import QuestionPage from './pages/Question' import QuestionPage from './pages/Question'
import Login from './pages/Login' import Login from './pages/Login'
import Home from './pages/Home' import Home from './pages/Home'
@ -21,7 +22,15 @@ const App: React.FC = () => {
{/* 不带TabBar的页面但需要登录保护 */} {/* 不带TabBar的页面但需要登录保护 */}
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} /> <Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
<Route path="/question-management" element={<ProtectedRoute><QuestionManagement /></ProtectedRoute>} />
{/* 题库管理页面,需要管理员权限 */}
<Route path="/question-management" element={
<ProtectedRoute>
<AdminRoute>
<QuestionManagement />
</AdminRoute>
</ProtectedRoute>
} />
{/* 不带TabBar的页面不需要登录保护 */} {/* 不带TabBar的页面不需要登录保护 */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />

View File

@ -0,0 +1,47 @@
import React, { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { message } from 'antd'
interface AdminRouteProps {
children: React.ReactNode
}
const AdminRoute: React.FC<AdminRouteProps> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
useEffect(() => {
// 检查用户信息
const userStr = localStorage.getItem('user')
if (!userStr) {
setIsAdmin(false)
return
}
try {
const user = JSON.parse(userStr)
if (user.username === 'yanlongqi') {
setIsAdmin(true)
} else {
setIsAdmin(false)
message.error('无权限访问该页面')
}
} catch (e) {
setIsAdmin(false)
}
}, [])
// 正在检查权限时,不显示任何内容
if (isAdmin === null) {
return null
}
// 如果不是管理员,重定向到首页
if (!isAdmin) {
return <Navigate to="/" replace />
}
// 是管理员,显示子组件
return <>{children}</>
}
export default AdminRoute

View File

@ -1,5 +1,6 @@
.layout { .layout {
min-height: 100vh; min-height: 100vh;
background: #fafafa;
} }
.content { .content {
@ -13,8 +14,14 @@
right: 0; right: 0;
z-index: 1000; z-index: 1000;
padding: 0; padding: 0;
background: white; background: rgba(255, 255, 255, 0.85);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 -2px 8px rgba(0, 0, 0, 0.04),
0 -1px 4px rgba(0, 0, 0, 0.02),
0 0 0 1px rgba(0, 0, 0, 0.03);
border-top: 0.5px solid rgba(0, 0, 0, 0.04);
} }
.menu { .menu {

View File

@ -1,61 +1,17 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useNavigate, useLocation, Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { Layout, Menu } from 'antd' import { Layout } from 'antd'
import {
HomeOutlined,
FileTextOutlined,
} from '@ant-design/icons'
import styles from './TabBarLayout.module.less' import styles from './TabBarLayout.module.less'
const { Footer, Content } = Layout const { Content } = Layout
const TabBarLayout: React.FC = () => { const TabBarLayout: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768)
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const menuItems = [
{
key: '/',
icon: <HomeOutlined />,
label: '首页',
},
{
key: '/question',
icon: <FileTextOutlined />,
label: '答题',
},
]
const handleMenuClick = (key: string) => {
navigate(key)
}
return ( return (
<Layout className={styles.layout}> <Layout className={styles.layout}>
<Content className={styles.content}> <Content className={styles.content}>
<Outlet /> <Outlet />
</Content> </Content>
{isMobile && (
<Footer className={styles.footer}>
<Menu
mode="horizontal"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => handleMenuClick(key)}
className={styles.menu}
/>
</Footer>
)}
</Layout> </Layout>
) )
} }

View File

@ -1,6 +1,6 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #fafafa;
padding: 0; padding: 0;
} }
@ -20,8 +20,15 @@
} }
.card { .card {
border-radius: 16px; border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.06),
0 1px 8px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
margin-bottom: 20px; margin-bottom: 20px;
} }
@ -40,7 +47,7 @@
} }
.card { .card {
border-radius: 12px; border-radius: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@ -1,7 +1,7 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #fafafa;
padding: 24px; padding: 20px;
padding-bottom: 80px; padding-bottom: 80px;
} }
@ -9,29 +9,35 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 32px; margin-bottom: 24px;
color: white;
.headerLeft { .headerLeft {
flex: 1; flex: 1;
} }
.title { .title {
color: white !important; color: #1d1d1f !important;
margin-bottom: 8px !important; margin-bottom: 4px !important;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
font-weight: 700;
} }
.subtitle { .subtitle {
color: rgba(255, 255, 255, 0.9); color: #6e6e73;
font-size: 14px;
} }
.userInfo { .userInfo {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.85);
padding: 12px 20px; padding: 10px 16px;
border-radius: 12px; border-radius: 16px;
backdrop-filter: blur(10px); backdrop-filter: blur(30px) saturate(180%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.04),
0 1px 3px rgba(0, 0, 0, 0.02),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
.userDetails { .userDetails {
display: flex; display: flex;
@ -39,12 +45,13 @@
gap: 2px; gap: 2px;
.userNickname { .userNickname {
color: white !important; color: #1d1d1f !important;
font-size: 16px; font-size: 15px;
font-weight: 600;
} }
.userUsername { .userUsername {
color: rgba(255, 255, 255, 0.8) !important; color: #6e6e73 !important;
font-size: 12px; font-size: 12px;
} }
} }
@ -52,119 +59,284 @@
} }
.statsCard { .statsCard {
margin-bottom: 32px; margin-bottom: 24px;
border-radius: 16px; border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
:global {
.ant-statistic-title {
font-size: 13px;
margin-bottom: 4px;
}
}
} }
.typeSection { .typeSection {
margin-bottom: 32px; margin-bottom: 24px;
.sectionTitle { .sectionTitle {
color: white !important; color: #1d1d1f !important;
margin-bottom: 16px !important; margin-bottom: 16px !important;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-weight: 700;
font-size: 18px !important;
} }
} }
.typeCard { .typeCard {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease; backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
height: 100%; height: 100%;
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); background: rgba(255, 255, 255, 0.95);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 4px 16px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.04);
} }
.typeIcon { .typeIcon {
margin-bottom: 12px; margin-bottom: 10px;
} }
.typeTitle { .typeTitle {
margin: 8px 0 !important; margin: 6px 0 4px 0 !important;
color: #1d1d1f;
font-weight: 600;
font-size: 16px !important;
} }
.typeDesc { .typeDesc {
margin: 0 !important; margin: 0 !important;
color: #6e6e73;
font-size: 12px;
} }
} }
.quickStart { .quickStart {
.sectionTitle {
color: #1d1d1f !important;
margin-bottom: 16px !important;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 18px !important;
}
.quickCard { .quickCard {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease; backdrop-filter: blur(30px) saturate(180%);
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); -webkit-backdrop-filter: blur(30px) saturate(180%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.05),
0 1px 4px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
height: 100%;
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); background: rgba(255, 255, 255, 0.95);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 4px 16px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.04);
} }
} }
} }
// 响应式设计 - 移动端 // 响应式设计 - 移动端 (< 768px)
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 16px; padding: 12px;
padding-bottom: 70px; padding-bottom: 70px;
} }
.header { .header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
margin-bottom: 24px; margin-bottom: 16px;
.headerLeft { .headerLeft {
margin-bottom: 16px; margin-bottom: 12px;
} }
.title { .title {
font-size: 24px !important; font-size: 22px !important;
}
.subtitle {
font-size: 13px;
} }
.userInfo { .userInfo {
width: 100%; width: 100%;
padding: 8px 12px;
:global {
.ant-space {
width: 100%;
justify-content: space-between;
}
.ant-btn {
font-size: 12px;
padding: 4px 8px;
}
}
} }
} }
.statsCard { .statsCard {
margin-bottom: 24px; margin-bottom: 16px;
:global {
.ant-statistic-title {
font-size: 12px;
}
.ant-statistic-content {
font-size: 20px !important;
}
}
} }
.typeSection { .typeSection {
margin-bottom: 24px; margin-bottom: 16px;
.sectionTitle { .sectionTitle {
font-size: 18px !important; font-size: 16px !important;
margin-bottom: 12px !important;
}
}
.typeCard {
border-radius: 12px;
.typeIcon {
font-size: 32px !important;
}
.typeTitle {
font-size: 14px !important;
}
.typeDesc {
font-size: 11px;
}
}
.quickStart {
.sectionTitle {
font-size: 16px !important;
margin-bottom: 12px !important;
}
.quickCard {
border-radius: 12px;
:global {
.ant-space {
gap: 12px !important;
}
h5 {
font-size: 14px !important;
}
.ant-typography {
font-size: 12px !important;
}
}
} }
} }
} }
// 响应式设计 - 平板 // 响应式设计 - 平板 (769px - 1024px)
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
.container { .container {
padding: 32px; padding: 24px;
padding-bottom: 80px;
}
}
// 响应式设计 - PC端
@media (min-width: 1025px) {
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px;
padding-bottom: 80px; padding-bottom: 80px;
} }
.header { .header {
.title { .title {
font-size: 28px !important;
}
}
.typeCard {
.typeIcon {
font-size: 36px !important; font-size: 36px !important;
} }
} }
} }
// 响应式设计 - PC端宽屏 (> 1025px)
@media (min-width: 1025px) {
.container {
max-width: 1400px;
margin: 0 auto;
padding: 32px 40px;
padding-bottom: 80px;
}
.header {
margin-bottom: 32px;
.title {
font-size: 32px !important;
}
.subtitle {
font-size: 15px;
}
}
.statsCard {
margin-bottom: 32px;
}
.typeSection {
margin-bottom: 32px;
.sectionTitle {
font-size: 20px !important;
}
}
.quickStart {
.sectionTitle {
font-size: 20px !important;
}
}
}
// 响应式设计 - 超宽屏 (> 1600px)
@media (min-width: 1600px) {
.container {
max-width: 1600px;
}
}

View File

@ -208,9 +208,9 @@ const Home: React.FC = () => {
<Title level={4} className={styles.sectionTitle}> <Title level={4} className={styles.sectionTitle}>
<FileTextOutlined /> <FileTextOutlined />
</Title> </Title>
<Row gutter={[16, 16]}> <Row gutter={[12, 12]}>
{questionTypes.map(type => ( {questionTypes.map(type => (
<Col xs={24} sm={12} md={8} lg={8} key={type.key}> <Col xs={12} sm={12} md={8} lg={6} xl={4} key={type.key}>
<Card <Card
hoverable hoverable
className={styles.typeCard} className={styles.typeCard}
@ -218,11 +218,11 @@ const Home: React.FC = () => {
styles={{ styles={{
body: { body: {
textAlign: 'center', textAlign: 'center',
padding: '24px', padding: '20px 12px',
} }
}} }}
> >
<div className={styles.typeIcon} style={{ color: type.color, fontSize: '48px' }}> <div className={styles.typeIcon} style={{ color: type.color, fontSize: '40px' }}>
{type.icon} {type.icon}
</div> </div>
<Title level={5} className={styles.typeTitle}>{type.title}</Title> <Title level={5} className={styles.typeTitle}>{type.title}</Title>
@ -235,55 +235,67 @@ const Home: React.FC = () => {
{/* 快速开始 */} {/* 快速开始 */}
<div className={styles.quickStart}> <div className={styles.quickStart}>
<Card <Title level={4} className={styles.sectionTitle}>
hoverable <RocketOutlined />
className={styles.quickCard} </Title>
onClick={() => navigate('/question')} <Row gutter={[12, 12]}>
> <Col xs={24} sm={24} md={12} lg={8}>
<Space align="center" size="large"> <Card
<div className={styles.quickIcon}> hoverable
<RocketOutlined style={{ fontSize: '32px', color: '#722ed1' }} /> className={styles.quickCard}
</div> onClick={() => navigate('/question')}
<div> >
<Title level={5} style={{ margin: 0 }}></Title> <Space align="center" size="middle" style={{ width: '100%' }}>
<Paragraph type="secondary" style={{ margin: 0 }}></Paragraph> <div className={styles.quickIcon}>
</div> <RocketOutlined style={{ fontSize: '32px', color: '#722ed1' }} />
</Space> </div>
</Card> <div style={{ flex: 1 }}>
<Title level={5} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}></Paragraph>
</div>
</Space>
</Card>
</Col>
<Card <Col xs={24} sm={24} md={12} lg={8}>
hoverable <Card
className={styles.quickCard} hoverable
onClick={() => navigate('/wrong-questions')} className={styles.quickCard}
style={{ marginTop: '16px' }} onClick={() => navigate('/wrong-questions')}
> >
<Space align="center" size="large"> <Space align="center" size="middle" style={{ width: '100%' }}>
<div className={styles.quickIcon}> <div className={styles.quickIcon}>
<BookOutlined style={{ fontSize: '32px', color: '#ff4d4f' }} /> <BookOutlined style={{ fontSize: '32px', color: '#ff4d4f' }} />
</div> </div>
<div> <div style={{ flex: 1 }}>
<Title level={5} style={{ margin: 0 }}></Title> <Title level={5} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: 0 }}></Paragraph> <Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}></Paragraph>
</div> </div>
</Space> </Space>
</Card> </Card>
</Col>
<Card {/* 仅 yanlongqi 用户显示题库管理 */}
hoverable {userInfo?.username === 'yanlongqi' && (
className={styles.quickCard} <Col xs={24} sm={24} md={12} lg={8}>
onClick={() => navigate('/question-management')} <Card
style={{ marginTop: '16px' }} hoverable
> className={styles.quickCard}
<Space align="center" size="large"> onClick={() => navigate('/question-management')}
<div className={styles.quickIcon}> >
<SettingOutlined style={{ fontSize: '32px', color: '#13c2c2' }} /> <Space align="center" size="middle" style={{ width: '100%' }}>
</div> <div className={styles.quickIcon}>
<div> <SettingOutlined style={{ fontSize: '32px', color: '#13c2c2' }} />
<Title level={5} style={{ margin: 0 }}></Title> </div>
<Paragraph type="secondary" style={{ margin: 0 }}></Paragraph> <div style={{ flex: 1 }}>
</div> <Title level={5} style={{ margin: 0 }}></Title>
</Space> <Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}></Paragraph>
</Card> </div>
</Space>
</Card>
</Col>
)}
</Row>
</div> </div>
</div> </div>
) )

View File

@ -3,7 +3,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #fafafa;
padding: 20px; padding: 20px;
} }
@ -13,8 +13,15 @@
} }
.card { .card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); background: rgba(255, 255, 255, 0.95);
border-radius: 16px; backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.06),
0 2px 12px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
border-radius: 24px;
:global { :global {
.ant-card-body { .ant-card-body {
@ -30,14 +37,27 @@
.title { .title {
margin: 0 0 8px 0 !important; margin: 0 0 8px 0 !important;
color: #667eea !important; color: #007aff !important;
font-weight: 800; font-weight: 800;
letter-spacing: 2px; letter-spacing: 1px;
} }
.subtitle { .subtitle {
font-size: 15px; font-size: 15px;
display: block; display: block;
color: #6e6e73;
}
.registerTip {
text-align: center;
margin-top: 16px;
font-size: 14px;
:global {
.ant-typography {
margin-right: 4px;
}
}
} }
// 响应式设计 - 移动端 // 响应式设计 - 移动端

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Card, Tabs, message, Typography } from 'antd' import { Form, Input, Button, Card, Modal, message, Typography } from 'antd'
import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons' import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons'
import styles from './Login.module.less' import styles from './Login.module.less'
const { Title, Text } = Typography const { Title, Text, Link } = Typography
interface LoginResponse { interface LoginResponse {
success: boolean success: boolean
@ -21,8 +21,8 @@ interface LoginResponse {
const Login: React.FC = () => { const Login: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('login')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [registerModalVisible, setRegisterModalVisible] = useState(false)
const [loginForm] = Form.useForm() const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm() const [registerForm] = Form.useForm()
@ -84,6 +84,7 @@ const Login: React.FC = () => {
localStorage.setItem('user', JSON.stringify(data.data.user)) localStorage.setItem('user', JSON.stringify(data.data.user))
message.success('注册成功') message.success('注册成功')
setRegisterModalVisible(false)
navigate('/') navigate('/')
} else { } else {
message.error(data.message || '注册失败') message.error(data.message || '注册失败')
@ -96,94 +97,6 @@ const Login: React.FC = () => {
} }
} }
const loginTabContent = (
<Form
form={loginForm}
name="login"
onFinish={handleLogin}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
)
const registerTabContent = (
<Form
form={registerForm}
name="register"
onFinish={handleRegister}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少6位' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码至少6位"
/>
</Form.Item>
<Form.Item
name="nickname"
rules={[{ max: 20, message: '昵称最多20个字符' }]}
>
<Input
prefix={<IdcardOutlined />}
placeholder="请输入昵称(可选)"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
)
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.content}> <div className={styles.content}>
@ -193,25 +106,109 @@ const Login: React.FC = () => {
<Text type="secondary" className={styles.subtitle}>使</Text> <Text type="secondary" className={styles.subtitle}>使</Text>
</div> </div>
<Tabs <Form
activeKey={activeTab} form={loginForm}
onChange={setActiveTab} name="login"
centered onFinish={handleLogin}
items={[ autoComplete="off"
{ size="large"
key: 'login', >
label: '登录', <Form.Item
children: loginTabContent, name="username"
}, rules={[{ required: true, message: '请输入用户名' }]}
{ >
key: 'register', <Input
label: '注册', prefix={<UserOutlined />}
children: registerTabContent, placeholder="请输入用户名"
}, />
]} </Form.Item>
/>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
<div className={styles.registerTip}>
<Text type="secondary"></Text>
<Link onClick={() => setRegisterModalVisible(true)}></Link>
</div>
</Form>
</Card> </Card>
</div> </div>
{/* 注册模态框 */}
<Modal
title="注册新账号"
open={registerModalVisible}
onCancel={() => {
setRegisterModalVisible(false)
registerForm.resetFields()
}}
footer={null}
destroyOnClose
>
<Form
form={registerForm}
name="register"
onFinish={handleRegister}
autoComplete="off"
size="large"
style={{ marginTop: 24 }}
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少6位' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码至少6位"
/>
</Form.Item>
<Form.Item
name="nickname"
rules={[{ max: 20, message: '昵称最多20个字符' }]}
>
<Input
prefix={<IdcardOutlined />}
placeholder="请输入昵称(可选)"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
</Modal>
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #fafafa;
padding: 20px; padding: 20px;
padding-bottom: 80px; padding-bottom: 80px;
} }
@ -17,32 +17,43 @@
margin-bottom: 24px; margin-bottom: 24px;
.title { .title {
color: white !important; color: #1d1d1f !important;
margin: 0 !important; margin: 0 !important;
font-weight: 700;
} }
} }
.questionCard { .questionCard {
border-radius: 16px; border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.06),
0 1px 8px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
margin-bottom: 24px; margin-bottom: 24px;
} }
.questionNumber { .questionNumber {
color: #666; color: #6e6e73;
margin: 8px 0 16px 0 !important; margin: 8px 0 16px 0 !important;
} }
.questionContent { .questionContent {
font-size: 18px; font-size: 18px;
line-height: 1.6; line-height: 1.6;
color: #333; color: #1d1d1f;
margin-bottom: 16px; margin-bottom: 16px;
} }
.fillInput { .fillInput {
border-bottom: 2px solid #1677ff !important; border-bottom: 2px solid #007aff !important;
border-radius: 0 !important; border-radius: 0 !important;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
} }
.buttonGroup { .buttonGroup {
@ -74,7 +85,7 @@
} }
.questionCard { .questionCard {
border-radius: 12px; border-radius: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@ -1,6 +1,6 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #fafafa;
padding: 0; padding: 0;
} }
@ -10,24 +10,32 @@
} }
.backButton { .backButton {
color: white; color: #007aff;
margin-bottom: 12px; margin-bottom: 12px;
&:hover { &:hover {
color: rgba(255, 255, 255, 0.85); color: #0051d5;
} }
} }
.title { .title {
color: white !important; color: #1d1d1f !important;
margin: 0 !important; margin: 0 !important;
font-size: 28px; font-size: 28px;
font-weight: 700;
} }
.statsCard { .statsCard {
margin: 0 20px 20px; margin: 0 20px 20px;
border-radius: 16px; border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.06),
0 1px 8px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
} }
.actions { .actions {
@ -38,14 +46,21 @@
.listCard { .listCard {
margin: 0 20px 20px; margin: 0 20px 20px;
border-radius: 16px; border-radius: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.06),
0 1px 8px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.03);
border: 0.5px solid rgba(0, 0, 0, 0.04);
padding-bottom: 60px; // 为底部导航留空间 padding-bottom: 60px; // 为底部导航留空间
} }
.listItem { .listItem {
padding: 16px 0 !important; padding: 16px 0 !important;
border-bottom: 1px solid #f0f0f0 !important; border-bottom: 1px solid rgba(0, 0, 0, 0.04) !important;
&:last-child { &:last-child {
border-bottom: none !important; border-bottom: none !important;
@ -54,6 +69,7 @@
.questionContent { .questionContent {
margin-top: 8px; margin-top: 8px;
color: #1d1d1f;
} }
// 响应式设计 - 移动端 // 响应式设计 - 移动端
@ -68,7 +84,7 @@
.statsCard { .statsCard {
margin: 0 16px 16px; margin: 0 16px 16px;
border-radius: 12px; border-radius: 16px;
} }
.actions { .actions {
@ -77,7 +93,7 @@
.listCard { .listCard {
margin: 0 16px 16px; margin: 0 16px 16px;
border-radius: 12px; border-radius: 16px;
} }
.listItem { .listItem {

View File

@ -34,8 +34,12 @@ instance.interceptors.response.use(
if (error.response) { if (error.response) {
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
// 未授权,跳转到登录页 // 未授权,清除本地存储并跳转到登录页
console.error('未授权,请登录') console.error('Token已过期或未授权请重新登录')
localStorage.removeItem('token')
localStorage.removeItem('user')
// 跳转到登录页
window.location.href = '/login'
break break
case 403: case 403:
console.error('没有权限访问') console.error('没有权限访问')

View File

@ -14,11 +14,14 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
less: { less: {
javascriptEnabled: true, javascriptEnabled: true,
// antd 主题定制 // antd 主题定制 - 白色毛玻璃风格
modifyVars: { modifyVars: {
'@primary-color': '#1677ff', // 主色调 '@primary-color': '#007aff', // macOS 蓝色
'@link-color': '#1677ff', // 链接色 '@link-color': '#007aff', // 链接色
'@border-radius-base': '8px', // 组件圆角 '@border-radius-base': '12px', // 组件圆角
'@layout-body-background': '#ffffff', // 白色背景
'@component-background': 'rgba(255, 255, 255, 0.8)', // 半透明组件背景
'@border-color-base': 'rgba(0, 0, 0, 0.06)', // 边框色
}, },
}, },
}, },