将CSS迁移到Less并添加用户认证功能
主要变更: - 将所有CSS文件迁移到Less预处理器 - 配置Vite支持Less编译 - 使用Less变量、嵌套和父选择器优化样式代码 - 添加用户注册和登录功能 - 实现用户认证中间件和保护路由 - 新增Profile和Login页面 - 添加底部导航栏组件TabBarLayout - 更新CLAUDE.md为中文文档 技术改进: - Less变量统一管理主题色和间距 - CSS嵌套提高代码可读性和可维护性 - 模块化样式组织更清晰 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f791c235e1
commit
441eb215f6
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git commit:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
112
CLAUDE.md
112
CLAUDE.md
@ -1,106 +1,106 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
本文件为 Claude Code (claude.ai/code) 在此代码仓库中工作时提供指导。
|
||||||
|
|
||||||
## Project Overview
|
## 项目概述
|
||||||
|
|
||||||
AnKao is a Go web application built using the **Gin framework**. The project follows a clean architecture pattern with clear separation of concerns and is designed to support database integration.
|
AnKao 是一个使用 **Gin 框架**构建的 Go Web 应用程序。该项目遵循整洁架构模式,具有清晰的关注点分离,并设计为支持数据库集成。
|
||||||
|
|
||||||
## Architecture
|
## 架构
|
||||||
|
|
||||||
### Request Flow
|
### 请求流程
|
||||||
1. HTTP requests arrive at the Gin server running on port 8080
|
1. HTTP 请求到达运行在 8080 端口的 Gin 服务器
|
||||||
2. Requests pass through Gin's default middleware (Logger, Recovery) and custom middleware
|
2. 请求通过 Gin 的默认中间件(Logger、Recovery)和自定义中间件
|
||||||
3. The Gin router matches routes and directs requests to appropriate handlers
|
3. Gin 路由器匹配路由并将请求定向到相应的处理器
|
||||||
4. Handlers (in `internal/handlers/`) process requests using `*gin.Context` and return JSON responses
|
4. 处理器(位于 `internal/handlers/`)使用 `*gin.Context` 处理请求并返回 JSON 响应
|
||||||
|
|
||||||
### Middleware Pattern
|
### 中间件模式
|
||||||
Middleware in Gin uses the `gin.HandlerFunc` pattern:
|
Gin 中的中间件使用 `gin.HandlerFunc` 模式:
|
||||||
```go
|
```go
|
||||||
func MiddlewareName() gin.HandlerFunc {
|
func MiddlewareName() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Pre-processing
|
// 预处理
|
||||||
c.Next()
|
c.Next()
|
||||||
// Post-processing
|
// 后处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Middleware is registered in [main.go:15](main.go#L15) using `r.Use()`.
|
中间件在 [main.go:15](main.go#L15) 中使用 `r.Use()` 注册。
|
||||||
|
|
||||||
### Module Structure
|
### 模块结构
|
||||||
- **main.go** - Application entry point, server configuration and routing setup
|
- **main.go** - 应用程序入口点,服务器配置和路由设置
|
||||||
- **internal/handlers/** - HTTP request handlers, all use `*gin.Context` and return JSON responses
|
- **internal/handlers/** - HTTP 请求处理器,全部使用 `*gin.Context` 并返回 JSON 响应
|
||||||
- **internal/middleware/** - Gin middleware chain (currently: custom logger)
|
- **internal/middleware/** - Gin 中间件链(当前:自定义日志记录器)
|
||||||
- **internal/models/** - Data models (directory exists, ready for database models)
|
- **internal/models/** - 数据模型(目录已存在,准备用于数据库模型)
|
||||||
- **pkg/config/** - Configuration management (directory exists but not yet populated)
|
- **pkg/config/** - 配置管理(目录已存在但尚未填充)
|
||||||
|
|
||||||
## Common Commands
|
## 常用命令
|
||||||
|
|
||||||
### Development
|
### 开发
|
||||||
```bash
|
```bash
|
||||||
# Run the server
|
# 运行服务器
|
||||||
go run main.go
|
go run main.go
|
||||||
|
|
||||||
# Install/update dependencies
|
# 安装/更新依赖
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
# Format code
|
# 格式化代码
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
|
||||||
# Vet code for common issues
|
# 检查代码常见问题
|
||||||
go vet ./...
|
go vet ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
### 构建
|
||||||
```bash
|
```bash
|
||||||
# Build binary to bin/server
|
# 构建二进制文件到 bin/server
|
||||||
go build -o bin/server.exe main.go
|
go build -o bin/server.exe main.go
|
||||||
|
|
||||||
# Run built binary (Windows)
|
# 运行构建的二进制文件 (Windows)
|
||||||
.\bin\server.exe
|
.\bin\server.exe
|
||||||
|
|
||||||
# Run built binary (Unix)
|
# 运行构建的二进制文件 (Unix)
|
||||||
./bin/server
|
./bin/server
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### 测试
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# 运行所有测试
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
# Run tests with coverage
|
# 运行带覆盖率的测试
|
||||||
go test -cover ./...
|
go test -cover ./...
|
||||||
|
|
||||||
# Run tests in a specific package
|
# 运行特定包的测试
|
||||||
go test ./internal/handlers/
|
go test ./internal/handlers/
|
||||||
|
|
||||||
# Run tests with verbose output
|
# 运行详细输出的测试
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Implementation Details
|
## 关键实现细节
|
||||||
|
|
||||||
- **Framework**: Using Gin v1.11.0
|
- **框架**: 使用 Gin v1.11.0
|
||||||
- **Server Port**: :8080 (configured in [main.go:22](main.go#L22))
|
- **服务器端口**: :8080 (在 [main.go:22](main.go#L22) 中配置)
|
||||||
- **Handler Signature**: All handlers use `func(c *gin.Context)` pattern
|
- **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式
|
||||||
- **JSON Response**: Use `c.JSON()` method with `gin.H{}` or structs
|
- **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体
|
||||||
- **Import Paths**: Use module name `ankao` (defined in go.mod)
|
- **导入路径**: 使用模块名 `ankao` (在 go.mod 中定义)
|
||||||
- **Route Registration**: Routes are registered in [main.go](main.go) using `r.GET()`, `r.POST()`, etc.
|
- **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()`、`r.POST()` 等注册
|
||||||
- **Middleware**: Applied globally with `r.Use()` or per-route with route grouping
|
- **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由
|
||||||
|
|
||||||
## Adding New Features
|
## 添加新功能
|
||||||
|
|
||||||
### Adding a New Handler
|
### 添加新处理器
|
||||||
1. Create handler function in `internal/handlers/` with signature `func(c *gin.Context)`
|
1. 在 `internal/handlers/` 中创建处理器函数,签名为 `func(c *gin.Context)`
|
||||||
2. Use `c.JSON()` to return responses
|
2. 使用 `c.JSON()` 返回响应
|
||||||
3. Register route in [main.go](main.go) (e.g., `r.GET("/path", handlers.YourHandler)`)
|
3. 在 [main.go](main.go) 中注册路由(例如:`r.GET("/path", handlers.YourHandler)`)
|
||||||
|
|
||||||
### Adding Middleware
|
### 添加中间件
|
||||||
1. Create middleware in `internal/middleware/` returning `gin.HandlerFunc`
|
1. 在 `internal/middleware/` 中创建返回 `gin.HandlerFunc` 的中间件
|
||||||
2. Apply globally with `r.Use(middleware.YourMiddleware())` or to route groups
|
2. 使用 `r.Use(middleware.YourMiddleware())` 全局应用或应用到路由组
|
||||||
|
|
||||||
### Database Integration
|
### 数据库集成
|
||||||
The project is ready for database integration:
|
项目已准备好进行数据库集成:
|
||||||
- Add models in `internal/models/`
|
- 在 `internal/models/` 中添加模型
|
||||||
- Consider using GORM or similar ORM
|
- 考虑使用 GORM 或类似的 ORM
|
||||||
- Add database initialization in main.go or separate package
|
- 在 main.go 或单独的包中添加数据库初始化
|
||||||
|
|||||||
62
README.md
62
README.md
@ -29,7 +29,18 @@ go run main.go
|
|||||||
|
|
||||||
### API端点
|
### API端点
|
||||||
|
|
||||||
- `GET /` - 首页,返回欢迎信息
|
#### 用户相关
|
||||||
|
- `POST /api/login` - 用户登录
|
||||||
|
|
||||||
|
#### 题目相关
|
||||||
|
- `GET /api/questions` - 获取题目列表
|
||||||
|
- `GET /api/questions/random` - 获取随机题目
|
||||||
|
- `GET /api/questions/:id` - 获取指定题目
|
||||||
|
- `POST /api/submit` - 提交答案
|
||||||
|
- `GET /api/statistics` - 获取统计数据
|
||||||
|
- `POST /api/reset` - 重置进度
|
||||||
|
|
||||||
|
#### 其他
|
||||||
- `GET /api/health` - 健康检查端点
|
- `GET /api/health` - 健康检查端点
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
@ -62,7 +73,54 @@ go build -o bin/server.exe main.go
|
|||||||
- 自定义日志中间件
|
- 自定义日志中间件
|
||||||
- RESTful API 结构
|
- RESTful API 结构
|
||||||
- 健康检查端点
|
- 健康检查端点
|
||||||
- 支持数据库集成(预留结构)
|
- 用户登录系统(基于JSON文件存储)
|
||||||
|
- 题目练习功能
|
||||||
|
- 答题统计功能
|
||||||
|
- React + TypeScript + Vite 前端
|
||||||
|
- Ant Design Mobile UI组件库
|
||||||
|
|
||||||
|
## 用户系统
|
||||||
|
|
||||||
|
用户数据存储在 [users.json](users.json) 文件中,格式如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"用户名": {
|
||||||
|
"password": "密码",
|
||||||
|
"avatar": "头像URL",
|
||||||
|
"nickname": "昵称"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试账号
|
||||||
|
|
||||||
|
- 用户名: `admin` / 密码: `123456`
|
||||||
|
- 用户名: `test` / 密码: `test123`
|
||||||
|
|
||||||
|
## 前端开发
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行开发服务器
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
- **首页(刷题页)** - 题目练习、随机题目、题目列表、筛选等功能
|
||||||
|
- **我的** - 用户信息、退出登录
|
||||||
|
- **登录页** - 用户登录
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
7
go.mod
7
go.mod
@ -2,11 +2,7 @@ module ankao
|
|||||||
|
|
||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
|
||||||
github.com/go-ole/go-ole v1.3.0
|
|
||||||
github.com/richardlehane/mscfb v1.0.3
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
@ -29,7 +25,6 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.3 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@ -15,8 +15,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@ -53,11 +51,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
|||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||||
github.com/richardlehane/mscfb v1.0.3 h1:rD8TBkYWkObWO0oLDFCbwMeZ4KoalxQy+QgniCj3nKI=
|
|
||||||
github.com/richardlehane/mscfb v1.0.3/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
|
||||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
||||||
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
|
|
||||||
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@ -85,7 +78,6 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
|||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|||||||
190
internal/handlers/user.go
Normal file
190
internal/handlers/user.go
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ankao/internal/models"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usersFilePath = "users.json"
|
||||||
|
|
||||||
|
// loadUsers 从JSON文件加载用户数据
|
||||||
|
func loadUsers() (models.UserData, error) {
|
||||||
|
file, err := os.ReadFile(usersFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取用户文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var users models.UserData
|
||||||
|
if err := json.Unmarshal(file, &users); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析用户数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveUsers 保存用户数据到JSON文件
|
||||||
|
func saveUsers(users models.UserData) error {
|
||||||
|
data, err := json.MarshalIndent(users, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化用户数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(usersFilePath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("保存用户文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken 生成简单的token(实际项目应使用JWT)
|
||||||
|
func generateToken(username string) string {
|
||||||
|
data := fmt.Sprintf("%s:%d", username, time.Now().Unix())
|
||||||
|
hash := sha256.Sum256([]byte(data))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户登录处理
|
||||||
|
func Login(c *gin.Context) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请求参数错误",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载用户数据
|
||||||
|
users, err := loadUsers()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "服务器错误",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户
|
||||||
|
user, exists := users[req.Username]
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户名或密码错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if user.Password != req.Password {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户名或密码错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成token
|
||||||
|
token := generateToken(req.Username)
|
||||||
|
|
||||||
|
// 返回用户信息(不包含密码)
|
||||||
|
userInfo := models.UserInfoResponse{
|
||||||
|
Username: req.Username,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "登录成功",
|
||||||
|
"data": models.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
User: userInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 用户注册处理
|
||||||
|
func Register(c *gin.Context) {
|
||||||
|
var req models.RegisterRequest
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请求参数错误",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载现有用户数据
|
||||||
|
users, err := loadUsers()
|
||||||
|
if err != nil {
|
||||||
|
// 如果文件不存在,创建新的用户数据
|
||||||
|
users = make(models.UserData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
if _, exists := users[req.Username]; exists {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户名已存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新用户
|
||||||
|
newUser := models.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password, // 实际项目应该加密存储
|
||||||
|
Nickname: req.Nickname,
|
||||||
|
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + req.Username, // 使用用户名生成默认头像
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有提供昵称,使用用户名
|
||||||
|
if newUser.Nickname == "" {
|
||||||
|
newUser.Nickname = req.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新用户
|
||||||
|
users[req.Username] = newUser
|
||||||
|
|
||||||
|
// 保存用户数据
|
||||||
|
if err := saveUsers(users); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "注册失败",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成token
|
||||||
|
token := generateToken(req.Username)
|
||||||
|
|
||||||
|
// 返回用户信息
|
||||||
|
userInfo := models.UserInfoResponse{
|
||||||
|
Username: newUser.Username,
|
||||||
|
Avatar: newUser.Avatar,
|
||||||
|
Nickname: newUser.Nickname,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "注册成功",
|
||||||
|
"data": models.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
User: userInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
22
internal/middleware/cors.go
Normal file
22
internal/middleware/cors.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS 跨域中间件
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/models/user.go
Normal file
38
internal/models/user.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// User 用户结构
|
||||||
|
type User struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserData 存储所有用户的结构
|
||||||
|
type UserData map[string]User
|
||||||
|
|
||||||
|
// LoginRequest 登录请求
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse 登录响应
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User UserInfoResponse `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfoResponse 用户信息响应(不包含密码)
|
||||||
|
type UserInfoResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRequest 注册请求
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
4
main.go
4
main.go
@ -24,6 +24,10 @@ func main() {
|
|||||||
// 健康检查
|
// 健康检查
|
||||||
api.GET("/health", handlers.HealthCheckHandler)
|
api.GET("/health", handlers.HealthCheckHandler)
|
||||||
|
|
||||||
|
// 用户相关API
|
||||||
|
api.POST("/login", handlers.Login) // 用户登录
|
||||||
|
api.POST("/register", handlers.Register) // 用户注册
|
||||||
|
|
||||||
// 题目相关API
|
// 题目相关API
|
||||||
api.GET("/questions", handlers.GetQuestions) // 获取题目列表
|
api.GET("/questions", handlers.GetQuestions) // 获取题目列表
|
||||||
api.GET("/questions/random", handlers.GetRandomQuestion) // 获取随机题目
|
api.GET("/questions/random", handlers.GetRandomQuestion) // 获取随机题目
|
||||||
|
|||||||
12
users.json
Normal file
12
users.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"admin": {
|
||||||
|
"password": "123456",
|
||||||
|
"avatar": "",
|
||||||
|
"nickname": "管理员"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"password": "test123",
|
||||||
|
"avatar": "",
|
||||||
|
"nickname": "测试用户"
|
||||||
|
}
|
||||||
|
}
|
||||||
3630
web/package-lock.json
generated
Normal file
3630
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,23 +10,24 @@
|
|||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-router-dom": "^6.21.3",
|
|
||||||
"antd-mobile": "^5.37.1",
|
"antd-mobile": "^5.37.1",
|
||||||
"antd-mobile-icons": "^0.3.0",
|
"antd-mobile-icons": "^0.3.0",
|
||||||
"axios": "^1.6.5"
|
"axios": "^1.6.5",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/node": "^20.11.5",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"less": "^4.4.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.11"
|
"vite": "^5.0.11"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import React from 'react'
|
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 ProtectedRoute from './components/ProtectedRoute'
|
||||||
import QuestionPage from './pages/Question'
|
import QuestionPage from './pages/Question'
|
||||||
|
import Profile from './pages/Profile'
|
||||||
|
import Login from './pages/Login'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import About from './pages/About'
|
import About from './pages/About'
|
||||||
|
|
||||||
@ -8,7 +12,14 @@ const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<QuestionPage />} />
|
{/* 带TabBar的页面,需要登录保护 */}
|
||||||
|
<Route element={<ProtectedRoute><TabBarLayout /></ProtectedRoute>}>
|
||||||
|
<Route path="/" element={<QuestionPage />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 不带TabBar的页面 */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/home" element={<Home />} />
|
<Route path="/home" element={<Home />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// 容器样式
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Button, Space } from 'antd-mobile'
|
import { Button, Space } from 'antd-mobile'
|
||||||
import styles from './DemoButton.module.css'
|
import styles from './DemoButton.module.less'
|
||||||
|
|
||||||
interface DemoButtonProps {
|
interface DemoButtonProps {
|
||||||
text?: string
|
text?: string
|
||||||
|
|||||||
19
web/src/components/ProtectedRoute.tsx
Normal file
19
web/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// 未登录,重定向到登录页
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute
|
||||||
21
web/src/components/TabBarLayout.less
Normal file
21
web/src/components/TabBarLayout.less
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// 布局容器
|
||||||
|
.tab-bar-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
|
.tab-bar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部导航栏
|
||||||
|
.tab-bar-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
47
web/src/components/TabBarLayout.tsx
Normal file
47
web/src/components/TabBarLayout.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||||
|
import { TabBar } from 'antd-mobile'
|
||||||
|
import {
|
||||||
|
AppOutline,
|
||||||
|
UserOutline,
|
||||||
|
} from 'antd-mobile-icons'
|
||||||
|
import './TabBarLayout.less'
|
||||||
|
|
||||||
|
const TabBarLayout: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: '/',
|
||||||
|
title: '首页',
|
||||||
|
icon: <AppOutline />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/profile',
|
||||||
|
title: '我的',
|
||||||
|
icon: <UserOutline />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const setRouteActive = (value: string) => {
|
||||||
|
navigate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-bar-layout">
|
||||||
|
<div className="tab-bar-content">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
<div className="tab-bar-footer">
|
||||||
|
<TabBar activeKey={location.pathname} onChange={setRouteActive}>
|
||||||
|
{tabs.map(item => (
|
||||||
|
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabBarLayout
|
||||||
@ -1,18 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
22
web/src/index.less
Normal file
22
web/src/index.less
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// 全局变量
|
||||||
|
@bg-color: #f5f5f5;
|
||||||
|
@font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
|
||||||
|
// 全局重置样式
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: @font-family;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: @bg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.less'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
|
// 变量
|
||||||
|
@bg-color: #f5f5f5;
|
||||||
|
|
||||||
|
// 容器
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: #f5f5f5;
|
background-color: @bg-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { NavBar, Card, List, Space, Button } from 'antd-mobile'
|
import { NavBar, Card, List, Space, Button } from 'antd-mobile'
|
||||||
import styles from './About.module.css'
|
import styles from './About.module.less'
|
||||||
|
|
||||||
const About: React.FC = () => {
|
const About: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
|
// 变量
|
||||||
|
@bg-color: #f5f5f5;
|
||||||
|
|
||||||
|
// 容器
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: #f5f5f5;
|
background-color: @bg-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
UnorderedListOutline,
|
UnorderedListOutline,
|
||||||
UserOutline
|
UserOutline
|
||||||
} from 'antd-mobile-icons'
|
} from 'antd-mobile-icons'
|
||||||
import styles from './Home.module.css'
|
import styles from './Home.module.less'
|
||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
222
web/src/pages/Login.less
Normal file
222
web/src/pages/Login.less
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
// 变量定义
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
web/src/pages/Login.tsx
Normal file
191
web/src/pages/Login.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Toast,
|
||||||
|
Tabs,
|
||||||
|
} from 'antd-mobile'
|
||||||
|
import axios from 'axios'
|
||||||
|
import './Login.less'
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('login')
|
||||||
|
|
||||||
|
const onLogin = async (values: { username: string; password: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/login', {
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
const { token, user } = response.data.data
|
||||||
|
|
||||||
|
// 保存token和用户信息
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
localStorage.setItem('userInfo', JSON.stringify(user))
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
icon: 'success',
|
||||||
|
content: '登录成功',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 跳转到首页
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
Toast.show({
|
||||||
|
icon: 'fail',
|
||||||
|
content: response.data.message || '登录失败',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('登录失败:', error)
|
||||||
|
Toast.show({
|
||||||
|
icon: 'fail',
|
||||||
|
content: error.response?.data?.message || '登录失败,请检查网络连接',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRegister = async (values: { username: string; password: string; nickname?: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/register', {
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
nickname: values.nickname || values.username,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
const { token, user } = response.data.data
|
||||||
|
|
||||||
|
// 保存token和用户信息
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
localStorage.setItem('userInfo', JSON.stringify(user))
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
icon: 'success',
|
||||||
|
content: '注册成功',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 跳转到首页
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
Toast.show({
|
||||||
|
icon: 'fail',
|
||||||
|
content: response.data.message || '注册失败',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('注册失败:', error)
|
||||||
|
Toast.show({
|
||||||
|
icon: 'fail',
|
||||||
|
content: error.response?.data?.message || '注册失败,请检查网络连接',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-content">
|
||||||
|
<div className="login-header">
|
||||||
|
<h1>AnKao</h1>
|
||||||
|
<p>欢迎使用AnKao题库系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(key) => setActiveTab(key)}
|
||||||
|
style={{ '--title-font-size': '16px' }}
|
||||||
|
>
|
||||||
|
<Tabs.Tab title="登录" key="login">
|
||||||
|
<Form
|
||||||
|
onFinish={onLogin}
|
||||||
|
layout="vertical"
|
||||||
|
footer={
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
42
web/src/pages/Profile.less
Normal file
42
web/src/pages/Profile.less
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// 变量定义
|
||||||
|
@bg-color: #f5f5f5;
|
||||||
|
@text-secondary: #999;
|
||||||
|
|
||||||
|
// 页面容器
|
||||||
|
.profile-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: @bg-color;
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户卡片
|
||||||
|
.user-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-nickname {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-username {
|
||||||
|
font-size: 14px;
|
||||||
|
color: @text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登出容器
|
||||||
|
.logout-container {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
130
web/src/pages/Profile.tsx
Normal file
130
web/src/pages/Profile.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Avatar,
|
||||||
|
List,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Toast,
|
||||||
|
} from 'antd-mobile'
|
||||||
|
import {
|
||||||
|
RightOutline,
|
||||||
|
SetOutline,
|
||||||
|
FileOutline,
|
||||||
|
UserOutline,
|
||||||
|
} from 'antd-mobile-icons'
|
||||||
|
import './Profile.less'
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Profile: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从 localStorage 获取用户信息
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const savedUserInfo = localStorage.getItem('userInfo')
|
||||||
|
|
||||||
|
if (token && savedUserInfo) {
|
||||||
|
try {
|
||||||
|
setUserInfo(JSON.parse(savedUserInfo))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析用户信息失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
const result = await Dialog.confirm({
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('userInfo')
|
||||||
|
setUserInfo(null)
|
||||||
|
Toast.show('已退出登录')
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-page">
|
||||||
|
{/* 用户信息卡片 */}
|
||||||
|
<Card className="user-card">
|
||||||
|
{userInfo ? (
|
||||||
|
<div className="user-info">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="user-info">
|
||||||
|
<Avatar style={{ '--size': '64px' }}>
|
||||||
|
<UserOutline fontSize={32} />
|
||||||
|
</Avatar>
|
||||||
|
<div className="user-details">
|
||||||
|
<div className="user-nickname">未登录</div>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleLogin}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
点击登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 功能列表 */}
|
||||||
|
<List header="功能">
|
||||||
|
<List.Item
|
||||||
|
prefix={<FileOutline />}
|
||||||
|
onClick={() => Toast.show('功能开发中')}
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
我的题目
|
||||||
|
<RightOutline />
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
prefix={<SetOutline />}
|
||||||
|
onClick={() => Toast.show('功能开发中')}
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
设置
|
||||||
|
<RightOutline />
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{/* 退出登录按钮 */}
|
||||||
|
{userInfo && (
|
||||||
|
<div className="logout-container">
|
||||||
|
<Button block color="danger" onClick={handleLogout}>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
@ -1,194 +0,0 @@
|
|||||||
.question-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding-bottom: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: #fff;
|
|
||||||
padding: 16px 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-number {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-content {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f6ffed;
|
|
||||||
border: 1px solid #b7eb8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result.wrong {
|
|
||||||
background: #fff2f0;
|
|
||||||
border: 1px solid #ffccc7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result .result-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result.correct .result-icon {
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result.wrong .result-icon {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result .result-text {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result .correct-answer {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-result .explanation {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #888;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-nav {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: #fff;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding: 12px 0;
|
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:active {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-content 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item span {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item strong {
|
|
||||||
font-size: 20px;
|
|
||||||
color: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-content h2 {
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group p {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 覆盖antd-mobile样式 */
|
|
||||||
.adm-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.adm-list-item {
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.adm-modal-body {
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
209
web/src/pages/Question.less
Normal file
209
web/src/pages/Question.less
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
// 变量定义
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ import {
|
|||||||
} from 'antd-mobile-icons'
|
} from 'antd-mobile-icons'
|
||||||
import type { Question, AnswerResult } from '../types/question'
|
import type { Question, AnswerResult } from '../types/question'
|
||||||
import * as questionApi from '../api/question'
|
import * as questionApi from '../api/question'
|
||||||
import './Question.css'
|
import './Question.less'
|
||||||
|
|
||||||
const QuestionPage: React.FC = () => {
|
const QuestionPage: React.FC = () => {
|
||||||
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
|
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
|
||||||
@ -327,34 +327,31 @@ const QuestionPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部导航 */}
|
{/* 功能按钮 */}
|
||||||
<div className="bottom-nav">
|
<div className="action-buttons">
|
||||||
<Button
|
<Button
|
||||||
className="nav-btn"
|
|
||||||
onClick={loadRandomQuestion}
|
onClick={loadRandomQuestion}
|
||||||
fill="none"
|
color="primary"
|
||||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
fill="outline"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 24 }}>🎲</span>
|
🎲 随机题目
|
||||||
<span style={{ fontSize: 12, marginTop: 4 }}>随机</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="nav-btn"
|
|
||||||
onClick={() => setListVisible(true)}
|
onClick={() => setListVisible(true)}
|
||||||
fill="none"
|
color="primary"
|
||||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
fill="outline"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<UnorderedListOutline fontSize={24} />
|
<UnorderedListOutline /> 题目列表
|
||||||
<span style={{ fontSize: 12, marginTop: 4 }}>列表</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="nav-btn"
|
|
||||||
onClick={() => setFilterVisible(true)}
|
onClick={() => setFilterVisible(true)}
|
||||||
fill="none"
|
color="primary"
|
||||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
fill="outline"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<FilterOutline fontSize={24} />
|
<FilterOutline /> 筛选
|
||||||
<span style={{ fontSize: 12, marginTop: 4 }}>筛选</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,17 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true,
|
||||||
|
// 可以在这里添加全局 Less 变量
|
||||||
|
modifyVars: {
|
||||||
|
// 例如: '@primary-color': '#1DA57A',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
882
web/yarn.lock
882
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user