将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:
燕陇琪 2025-11-03 23:21:38 +08:00
parent f791c235e1
commit 441eb215f6
34 changed files with 5374 additions and 808 deletions

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git commit:*)"
],
"deny": [],
"ask": []
}
}

112
CLAUDE.md
View File

@ -1,106 +1,106 @@
# 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
2. Requests pass through Gin's default middleware (Logger, Recovery) and custom middleware
3. The Gin router matches routes and directs requests to appropriate handlers
4. Handlers (in `internal/handlers/`) process requests using `*gin.Context` and return JSON responses
### 请求流程
1. HTTP 请求到达运行在 8080 端口的 Gin 服务器
2. 请求通过 Gin 的默认中间件(Logger、Recovery)和自定义中间件
3. Gin 路由器匹配路由并将请求定向到相应的处理器
4. 处理器(位于 `internal/handlers/`)使用 `*gin.Context` 处理请求并返回 JSON 响应
### Middleware Pattern
Middleware in Gin uses the `gin.HandlerFunc` pattern:
### 中间件模式
Gin 中的中间件使用 `gin.HandlerFunc` 模式:
```go
func MiddlewareName() gin.HandlerFunc {
return func(c *gin.Context) {
// Pre-processing
// 预处理
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
- **internal/handlers/** - HTTP request handlers, all use `*gin.Context` and return JSON responses
- **internal/middleware/** - Gin middleware chain (currently: custom logger)
- **internal/models/** - Data models (directory exists, ready for database models)
- **pkg/config/** - Configuration management (directory exists but not yet populated)
### 模块结构
- **main.go** - 应用程序入口点,服务器配置和路由设置
- **internal/handlers/** - HTTP 请求处理器,全部使用 `*gin.Context` 并返回 JSON 响应
- **internal/middleware/** - Gin 中间件链(当前:自定义日志记录器)
- **internal/models/** - 数据模型(目录已存在,准备用于数据库模型)
- **pkg/config/** - 配置管理(目录已存在但尚未填充)
## Common Commands
## 常用命令
### Development
### 开发
```bash
# Run the server
# 运行服务器
go run main.go
# Install/update dependencies
# 安装/更新依赖
go mod tidy
# Format code
# 格式化代码
go fmt ./...
# Vet code for common issues
# 检查代码常见问题
go vet ./...
```
### Building
### 构建
```bash
# Build binary to bin/server
# 构建二进制文件到 bin/server
go build -o bin/server.exe main.go
# Run built binary (Windows)
# 运行构建的二进制文件 (Windows)
.\bin\server.exe
# Run built binary (Unix)
# 运行构建的二进制文件 (Unix)
./bin/server
```
### Testing
### 测试
```bash
# Run all tests
# 运行所有测试
go test ./...
# Run tests with coverage
# 运行带覆盖率的测试
go test -cover ./...
# Run tests in a specific package
# 运行特定包的测试
go test ./internal/handlers/
# Run tests with verbose output
# 运行详细输出的测试
go test -v ./...
```
## Key Implementation Details
## 关键实现细节
- **Framework**: Using Gin v1.11.0
- **Server Port**: :8080 (configured in [main.go:22](main.go#L22))
- **Handler Signature**: All handlers use `func(c *gin.Context)` pattern
- **JSON Response**: Use `c.JSON()` method with `gin.H{}` or structs
- **Import Paths**: Use module name `ankao` (defined in go.mod)
- **Route Registration**: Routes are registered in [main.go](main.go) using `r.GET()`, `r.POST()`, etc.
- **Middleware**: Applied globally with `r.Use()` or per-route with route grouping
- **框架**: 使用 Gin v1.11.0
- **服务器端口**: :8080 (在 [main.go:22](main.go#L22) 中配置)
- **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式
- **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体
- **导入路径**: 使用模块名 `ankao` (在 go.mod 中定义)
- **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()``r.POST()` 等注册
- **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由
## Adding New Features
## 添加新功能
### Adding a New Handler
1. Create handler function in `internal/handlers/` with signature `func(c *gin.Context)`
2. Use `c.JSON()` to return responses
3. Register route in [main.go](main.go) (e.g., `r.GET("/path", handlers.YourHandler)`)
### 添加新处理器
1. `internal/handlers/` 中创建处理器函数,签名为 `func(c *gin.Context)`
2. 使用 `c.JSON()` 返回响应
3. 在 [main.go](main.go) 中注册路由(例如:`r.GET("/path", handlers.YourHandler)`)
### Adding Middleware
1. Create middleware in `internal/middleware/` returning `gin.HandlerFunc`
2. Apply globally with `r.Use(middleware.YourMiddleware())` or to route groups
### 添加中间件
1. `internal/middleware/` 中创建返回 `gin.HandlerFunc` 的中间件
2. 使用 `r.Use(middleware.YourMiddleware())` 全局应用或应用到路由组
### Database Integration
The project is ready for database integration:
- Add models in `internal/models/`
- Consider using GORM or similar ORM
- Add database initialization in main.go or separate package
### 数据库集成
项目已准备好进行数据库集成:
- `internal/models/` 中添加模型
- 考虑使用 GORM 或类似的 ORM
- 在 main.go 或单独的包中添加数据库初始化

View File

@ -29,7 +29,18 @@ go run main.go
### 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` - 健康检查端点
## 开发
@ -62,7 +73,54 @@ go build -o bin/server.exe main.go
- 自定义日志中间件
- 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
View File

@ -2,11 +2,7 @@ module ankao
go 1.25.1
require (
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 github.com/gin-gonic/gin v1.11.0
require (
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/quic-go/qpack v0.5.1 // 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/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect

8
go.sum
View File

@ -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-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/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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

190
internal/handlers/user.go Normal file
View 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,
},
})
}

View 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
View 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"`
}

View File

@ -24,6 +24,10 @@ func main() {
// 健康检查
api.GET("/health", handlers.HealthCheckHandler)
// 用户相关API
api.POST("/login", handlers.Login) // 用户登录
api.POST("/register", handlers.Register) // 用户注册
// 题目相关API
api.GET("/questions", handlers.GetQuestions) // 获取题目列表
api.GET("/questions/random", handlers.GetRandomQuestion) // 获取随机题目

12
users.json Normal file
View File

@ -0,0 +1,12 @@
{
"admin": {
"password": "123456",
"avatar": "",
"nickname": "管理员"
},
"test": {
"password": "test123",
"avatar": "",
"nickname": "测试用户"
}
}

3630
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,23 +10,24 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"antd-mobile": "^5.37.1",
"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": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/node": "^20.11.5",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"less": "^4.4.2",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}

View File

@ -1,6 +1,10 @@
import React from 'react'
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 Profile from './pages/Profile'
import Login from './pages/Login'
import Home from './pages/Home'
import About from './pages/About'
@ -8,7 +12,14 @@ const App: React.FC = () => {
return (
<Router>
<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="/about" element={<About />} />
</Routes>

View File

@ -1,3 +1,4 @@
// 容器样式
.container {
width: 100%;
}

View File

@ -1,6 +1,6 @@
import React from 'react'
import { Button, Space } from 'antd-mobile'
import styles from './DemoButton.module.css'
import styles from './DemoButton.module.less'
interface DemoButtonProps {
text?: string

View 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

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

View 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

View File

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

View File

@ -1,7 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import './index.less'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -1,10 +1,15 @@
// 变量
@bg-color: #f5f5f5;
// 容器
.container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
background-color: @bg-color;
}
// 内容区域
.content {
flex: 1;
overflow-y: auto;

View File

@ -1,7 +1,7 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
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 navigate = useNavigate()

View File

@ -1,10 +1,15 @@
// 变量
@bg-color: #f5f5f5;
// 容器
.container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
background-color: @bg-color;
}
// 内容区域
.content {
flex: 1;
overflow-y: auto;

View File

@ -14,7 +14,7 @@ import {
UnorderedListOutline,
UserOutline
} from 'antd-mobile-icons'
import styles from './Home.module.css'
import styles from './Home.module.less'
const Home: React.FC = () => {
const navigate = useNavigate()

222
web/src/pages/Login.less Normal file
View 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
View 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

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

View File

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

View File

@ -22,7 +22,7 @@ import {
} from 'antd-mobile-icons'
import type { Question, AnswerResult } from '../types/question'
import * as questionApi from '../api/question'
import './Question.css'
import './Question.less'
const QuestionPage: React.FC = () => {
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
@ -327,34 +327,31 @@ const QuestionPage: React.FC = () => {
</Card>
</div>
{/* 底部导航 */}
<div className="bottom-nav">
{/* 功能按钮 */}
<div className="action-buttons">
<Button
className="nav-btn"
onClick={loadRandomQuestion}
fill="none"
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
color="primary"
fill="outline"
size="small"
>
<span style={{ fontSize: 24 }}>🎲</span>
<span style={{ fontSize: 12, marginTop: 4 }}></span>
🎲
</Button>
<Button
className="nav-btn"
onClick={() => setListVisible(true)}
fill="none"
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
color="primary"
fill="outline"
size="small"
>
<UnorderedListOutline fontSize={24} />
<span style={{ fontSize: 12, marginTop: 4 }}></span>
<UnorderedListOutline />
</Button>
<Button
className="nav-btn"
onClick={() => setFilterVisible(true)}
fill="none"
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
color="primary"
fill="outline"
size="small"
>
<FilterOutline fontSize={24} />
<span style={{ fontSize: 12, marginTop: 4 }}></span>
<FilterOutline />
</Button>
</div>

View File

@ -10,6 +10,17 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
// 可以在这里添加全局 Less 变量
modifyVars: {
// 例如: '@primary-color': '#1DA57A',
},
},
},
},
server: {
port: 3000,
proxy: {

File diff suppressed because it is too large Load Diff