实现完整的登录注册功能并优化前端组件
- 实现登录页面: - 添加登录和注册表单切换功能 - 使用antd-mobile组件(Form, Input, Button, Toast) - 白色背景设计,标题使用主题色 - 表单验证和错误提示 - 已登录用户自动重定向 - 完善用户认证: - 路由保护,未登录用户重定向到登录页 - 用户信息保存到localStorage - Profile页面支持退出登录 - 后端改进: - 启用CORS中间件支持跨域请求 - 更新开发规范: - 在CLAUDE.md中添加前端开发规范 - 明确UI组件使用原则:优先使用antd-mobile组件 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
441eb215f6
commit
92e8c9a94c
22
CLAUDE.md
22
CLAUDE.md
@ -104,3 +104,25 @@ go test -v ./...
|
||||
- 在 `internal/models/` 中添加模型
|
||||
- 考虑使用 GORM 或类似的 ORM
|
||||
- 在 main.go 或单独的包中添加数据库初始化
|
||||
|
||||
## 前端开发规范
|
||||
|
||||
### UI 组件使用原则
|
||||
**重要**: 在开发前端页面时,必须优先使用 UI 框架的组件。
|
||||
|
||||
- **优先使用 antd-mobile 组件**: 项目使用 antd-mobile 作为 UI 框架,开发时应优先查找并使用框架提供的组件
|
||||
- **常用组件示例**:
|
||||
- 表单输入: 使用 `Input` 组件,而非原生 `<input>`
|
||||
- 按钮: 使用 `Button` 组件,而非原生 `<button>`
|
||||
- 表单: 使用 `Form` 和 `Form.Item` 组件
|
||||
- 提示信息: 使用 `Toast` 组件,而非自定义提示框
|
||||
- 对话框: 使用 `Dialog` 组件
|
||||
- 列表: 使用 `List` 组件
|
||||
- **仅在必要时自定义**: 只有当 antd-mobile 没有提供对应组件时,才使用自定义组件
|
||||
- **样式处理**: 使用 CSS Modules (`.module.less`) 编写组件样式,避免全局样式污染
|
||||
|
||||
### 前端项目结构
|
||||
- **web/src/pages/** - 页面组件
|
||||
- **web/src/components/** - 可复用组件
|
||||
- **web/src/pages/*.module.less** - 页面样式文件 (CSS Modules)
|
||||
|
||||
|
||||
1
main.go
1
main.go
@ -12,6 +12,7 @@ func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// 应用自定义中间件
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Logger())
|
||||
|
||||
// 静态文件服务
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
// 变量定义
|
||||
@primary-color: #1677ff;
|
||||
@primary-hover: #40a9ff;
|
||||
@bg-color: #f5f5f5;
|
||||
@white: #fff;
|
||||
@text-color: #262626;
|
||||
@text-secondary: #999;
|
||||
@border-color: #d9d9d9;
|
||||
@input-bg: #f5f5f5;
|
||||
@shadow-sm: 0 2px 8px rgba(22, 119, 255, 0.15);
|
||||
@shadow-md: 0 4px 12px rgba(22, 119, 255, 0.25);
|
||||
@shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 页面容器
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background-color: @bg-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 登录内容卡片
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: @white;
|
||||
border-radius: 12px;
|
||||
padding: 40px 32px;
|
||||
box-shadow: @shadow-lg;
|
||||
|
||||
// 头部
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: @primary-color;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs 样式
|
||||
.adm-tabs {
|
||||
--active-line-color: @primary-color;
|
||||
--active-title-color: @primary-color;
|
||||
}
|
||||
|
||||
.adm-tabs-header {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.adm-tabs-tab {
|
||||
font-size: 16px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.adm-tabs-content {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
// Form 样式
|
||||
.adm-form {
|
||||
--border-inner: none;
|
||||
--border-top: none;
|
||||
--border-bottom: none;
|
||||
}
|
||||
|
||||
.adm-form-item {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Input 样式
|
||||
.adm-input-wrapper {
|
||||
position: relative;
|
||||
background: @input-bg !important;
|
||||
border: 1px solid @border-color !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-hover !important;
|
||||
background: #fafafa !important;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background: @white !important;
|
||||
border-color: @primary-color !important;
|
||||
border-width: 2px !important;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.adm-input {
|
||||
--font-size: 15px;
|
||||
--placeholder-color: #bfbfbf;
|
||||
--color: @text-color;
|
||||
padding: 12px 16px;
|
||||
font-weight: 400;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
|
||||
input {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// Button 样式
|
||||
.adm-button {
|
||||
--border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
height: 50px;
|
||||
margin-top: 24px;
|
||||
box-shadow: @shadow-sm;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-md;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框图标
|
||||
.adm-form-item:first-child .adm-input-wrapper::before {
|
||||
content: '👤';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 18px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.adm-form-item:first-child .adm-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.adm-form-item:nth-child(2) .adm-input-wrapper::before {
|
||||
content: '🔒';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 18px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.adm-form-item:nth-child(2) .adm-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.adm-form-item:nth-child(3) .adm-input-wrapper::before {
|
||||
content: '✨';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 18px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.adm-form-item:nth-child(3) .adm-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
background-color: @white;
|
||||
padding: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
max-width: 100%;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
padding: 60px 24px 24px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
web/src/pages/Login.module.less
Normal file
124
web/src/pages/Login.module.less
Normal file
@ -0,0 +1,124 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
padding: 40px 20px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--adm-color-primary);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
transition: all 0.3s;
|
||||
outline: none;
|
||||
background: white;
|
||||
|
||||
&:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background-color: #fee;
|
||||
border: 2px solid #fcc;
|
||||
border-radius: 8px;
|
||||
color: #c33;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-10px); }
|
||||
75% { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
.switchMode {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 5px;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #764ba2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,34 +1,65 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Toast,
|
||||
Tabs,
|
||||
} from 'antd-mobile'
|
||||
import axios from 'axios'
|
||||
import './Login.less'
|
||||
import { Button, Input, Form, Toast } from 'antd-mobile'
|
||||
import styles from './Login.module.less'
|
||||
|
||||
interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: {
|
||||
token: string
|
||||
user: {
|
||||
username: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('login')
|
||||
|
||||
const onLogin = async (values: { username: string; password: string }) => {
|
||||
// 表单字段
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [nickname, setNickname] = useState('')
|
||||
|
||||
// 如果已登录,重定向到首页
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '请输入用户名和密码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.post('/api/login', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
const response = await fetch('http://localhost:8080/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
const { token, user } = response.data.data
|
||||
const data: LoginResponse = await response.json()
|
||||
|
||||
// 保存token和用户信息
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('userInfo', JSON.stringify(user))
|
||||
if (data.success && data.data) {
|
||||
// 保存token到localStorage
|
||||
localStorage.setItem('token', data.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.data.user))
|
||||
|
||||
Toast.show({
|
||||
icon: 'success',
|
||||
@ -40,35 +71,54 @@ const Login: React.FC = () => {
|
||||
} else {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: response.data.message || '登录失败',
|
||||
content: data.message || '登录失败',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
} catch (err) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: error.response?.data?.message || '登录失败,请检查网络连接',
|
||||
content: '网络错误,请稍后重试',
|
||||
})
|
||||
console.error('登录错误:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onRegister = async (values: { username: string; password: string; nickname?: string }) => {
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
if (!username || !password) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '请输入用户名和密码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '密码长度至少6位',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.post('/api/register', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
nickname: values.nickname || values.username,
|
||||
const response = await fetch('http://localhost:8080/api/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password, nickname }),
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
const { token, user } = response.data.data
|
||||
const data: LoginResponse = await response.json()
|
||||
|
||||
// 保存token和用户信息
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('userInfo', JSON.stringify(user))
|
||||
if (data.success && data.data) {
|
||||
// 保存token到localStorage
|
||||
localStorage.setItem('token', data.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.data.user))
|
||||
|
||||
Toast.show({
|
||||
icon: 'success',
|
||||
@ -80,109 +130,100 @@ const Login: React.FC = () => {
|
||||
} else {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: response.data.message || '注册失败',
|
||||
content: data.message || '注册失败',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('注册失败:', error)
|
||||
} catch (err) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: error.response?.data?.message || '注册失败,请检查网络连接',
|
||||
content: '网络错误,请稍后重试',
|
||||
})
|
||||
console.error('注册错误:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<h1>AnKao</h1>
|
||||
<p>欢迎使用AnKao题库系统</p>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<p className={styles.subtitle}>欢迎使用AnKao题库系统</p>
|
||||
<h1 className={styles.title}>AnKao</h1>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
style={{ '--title-font-size': '16px' }}
|
||||
>
|
||||
<Tabs.Tab title="登录" key="login">
|
||||
<Form
|
||||
onFinish={onLogin}
|
||||
layout="vertical"
|
||||
footer={
|
||||
onFinish={isLogin ? handleLogin : handleRegister}
|
||||
className={styles.form}
|
||||
>
|
||||
<Form.Item label="用户名">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
placeholder="请输入用户名"
|
||||
disabled={loading}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="密码">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={isLogin ? '请输入密码' : '请输入密码(至少6位)'}
|
||||
disabled={loading}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{!isLogin && (
|
||||
<Form.Item label="昵称(可选)">
|
||||
<Input
|
||||
value={nickname}
|
||||
onChange={setNickname}
|
||||
placeholder="请输入昵称"
|
||||
disabled={loading}
|
||||
clearable
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Button
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="large"
|
||||
loading={loading}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" clearable />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input type="password" placeholder="请输入密码" clearable />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="注册" key="register">
|
||||
<Form
|
||||
onFinish={onRegister}
|
||||
layout="vertical"
|
||||
footer={
|
||||
<Button
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="large"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
注册
|
||||
{isLogin ? '登录' : '注册'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" clearable />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input type="password" placeholder="请输入密码" clearable />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
>
|
||||
<Input placeholder="请输入昵称(可选)" clearable />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
|
||||
<div className={styles.switchMode}>
|
||||
{isLogin ? (
|
||||
<p>
|
||||
还没有账号?
|
||||
<button
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={styles.linkBtn}
|
||||
disabled={loading}
|
||||
>
|
||||
立即注册
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
已有账号?
|
||||
<button
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={styles.linkBtn}
|
||||
disabled={loading}
|
||||
>
|
||||
立即登录
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
@text-secondary: #999;
|
||||
|
||||
// 页面容器
|
||||
.profile-page {
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background-color: @bg-color;
|
||||
padding: 16px;
|
||||
@ -11,32 +11,32 @@
|
||||
}
|
||||
|
||||
// 用户卡片
|
||||
.user-card {
|
||||
.userCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
.userDetails {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-nickname {
|
||||
.userNickname {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
.userUsername {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
// 登出容器
|
||||
.logout-container {
|
||||
.logoutContainer {
|
||||
margin-top: 24px;
|
||||
}
|
||||
@ -14,7 +14,7 @@ import {
|
||||
FileOutline,
|
||||
UserOutline,
|
||||
} from 'antd-mobile-icons'
|
||||
import './Profile.less'
|
||||
import styles from './Profile.module.less'
|
||||
|
||||
interface UserInfo {
|
||||
username: string
|
||||
@ -29,7 +29,7 @@ const Profile: React.FC = () => {
|
||||
useEffect(() => {
|
||||
// 从 localStorage 获取用户信息
|
||||
const token = localStorage.getItem('token')
|
||||
const savedUserInfo = localStorage.getItem('userInfo')
|
||||
const savedUserInfo = localStorage.getItem('user')
|
||||
|
||||
if (token && savedUserInfo) {
|
||||
try {
|
||||
@ -47,7 +47,7 @@ const Profile: React.FC = () => {
|
||||
|
||||
if (result) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
localStorage.removeItem('user')
|
||||
setUserInfo(null)
|
||||
Toast.show('已退出登录')
|
||||
navigate('/login')
|
||||
@ -59,29 +59,29 @@ const Profile: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className={styles.page}>
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className="user-card">
|
||||
<Card className={styles.userCard}>
|
||||
{userInfo ? (
|
||||
<div className="user-info">
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar
|
||||
src={userInfo.avatar || undefined}
|
||||
style={{ '--size': '64px' }}
|
||||
>
|
||||
{!userInfo.avatar && <UserOutline fontSize={32} />}
|
||||
</Avatar>
|
||||
<div className="user-details">
|
||||
<div className="user-nickname">{userInfo.nickname}</div>
|
||||
<div className="user-username">@{userInfo.username}</div>
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userNickname}>{userInfo.nickname}</div>
|
||||
<div className={styles.userUsername}>@{userInfo.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-info">
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar style={{ '--size': '64px' }}>
|
||||
<UserOutline fontSize={32} />
|
||||
</Avatar>
|
||||
<div className="user-details">
|
||||
<div className="user-nickname">未登录</div>
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userNickname}>未登录</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
@ -117,7 +117,7 @@ const Profile: React.FC = () => {
|
||||
|
||||
{/* 退出登录按钮 */}
|
||||
{userInfo && (
|
||||
<div className="logout-container">
|
||||
<div className={styles.logoutContainer}>
|
||||
<Button block color="danger" onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
|
||||
@ -1,209 +0,0 @@
|
||||
// 变量定义
|
||||
@bg-color: #f5f5f5;
|
||||
@white: #fff;
|
||||
@primary-color: #1677ff;
|
||||
@text-color: #333;
|
||||
@text-secondary: #666;
|
||||
@text-tertiary: #888;
|
||||
@border-color: #e8e8e8;
|
||||
@shadow-light: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
@shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
@success-bg: #f6ffed;
|
||||
@success-border: #b7eb8f;
|
||||
@success-color: #52c41a;
|
||||
@error-bg: #fff2f0;
|
||||
@error-border: #ffccc7;
|
||||
@error-color: #ff4d4f;
|
||||
|
||||
// 页面容器
|
||||
.question-page {
|
||||
min-height: 100vh;
|
||||
background: @bg-color;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
// 头部
|
||||
.header {
|
||||
background: @white;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: @shadow-light;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 题目部分
|
||||
.question-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: @text-color;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
// 答案结果
|
||||
.answer-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: @success-bg;
|
||||
border: 1px solid @success-border;
|
||||
|
||||
&.wrong {
|
||||
background: @error-bg;
|
||||
border: 1px solid @error-border;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&.correct .result-icon {
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&.wrong .result-icon {
|
||||
color: @error-color;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.correct-answer {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
font-size: 14px;
|
||||
color: @text-tertiary;
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid @border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.button-group {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: @white;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计内容
|
||||
.stats-content {
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选内容
|
||||
.filter-content {
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖 antd-mobile 样式
|
||||
.adm-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: @shadow-medium;
|
||||
}
|
||||
|
||||
.adm-list-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.adm-modal-body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
212
web/src/pages/Question.module.less
Normal file
212
web/src/pages/Question.module.less
Normal file
@ -0,0 +1,212 @@
|
||||
// 变量定义
|
||||
@bg-color: #f5f5f5;
|
||||
@white: #fff;
|
||||
@primary-color: #1677ff;
|
||||
@text-color: #333;
|
||||
@text-secondary: #666;
|
||||
@text-tertiary: #888;
|
||||
@border-color: #e8e8e8;
|
||||
@shadow-light: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
@shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
@success-bg: #f6ffed;
|
||||
@success-border: #b7eb8f;
|
||||
@success-color: #52c41a;
|
||||
@error-bg: #fff2f0;
|
||||
@error-border: #ffccc7;
|
||||
@error-color: #ff4d4f;
|
||||
|
||||
// 使用 :global 包裹所有样式,因为 Question 组件使用直接类名
|
||||
:global {
|
||||
// 页面容器
|
||||
.question-page {
|
||||
min-height: 100vh;
|
||||
background: @bg-color;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
// 头部
|
||||
.header {
|
||||
background: @white;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: @shadow-light;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 题目部分
|
||||
.question-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: @text-color;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
// 答案结果
|
||||
.answer-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: @success-bg;
|
||||
border: 1px solid @success-border;
|
||||
|
||||
&.wrong {
|
||||
background: @error-bg;
|
||||
border: 1px solid @error-border;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&.correct .result-icon {
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&.wrong .result-icon {
|
||||
color: @error-color;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.correct-answer {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
font-size: 14px;
|
||||
color: @text-tertiary;
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid @border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.button-group {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: @white;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计内容
|
||||
.stats-content {
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选内容
|
||||
.filter-content {
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖 antd-mobile 样式
|
||||
.adm-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: @shadow-medium;
|
||||
}
|
||||
|
||||
.adm-list-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.adm-modal-body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,7 @@ import {
|
||||
} from 'antd-mobile-icons'
|
||||
import type { Question, AnswerResult } from '../types/question'
|
||||
import * as questionApi from '../api/question'
|
||||
import './Question.less'
|
||||
import './Question.module.less'
|
||||
|
||||
const QuestionPage: React.FC = () => {
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user