重构登录页面UI并完善项目文档

主要改动:
- 重新设计登录页面,使用原生input替代antd-mobile组件以更好控制样式
- 添加密码可见性切换功能(眼睛图标)
- 实现登录/注册切换UI,注册链接移至底部
- 优化输入框样式,添加明显的边框和背景色
- 实现移动端防缩放功能(touch-action + JS事件监听)
- 配置Vite代理解决CORS问题,API请求使用相对路径
- 完善CLAUDE.md和README.md文档,添加文档同步更新规范
- 更新技术栈说明,明确使用yarn作为包管理工具

技术细节:
- 渐变背景(紫色主题)
- 白色输入框带半透明边框,聚焦时显示光晕效果
- 防止移动端双指缩放和双击缩放
- Vite代理配置: /api/* -> http://localhost:8080

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
燕陇琪 2025-11-04 00:35:17 +08:00
parent 92e8c9a94c
commit 9540478583
6 changed files with 390 additions and 120 deletions

View File

@ -2,6 +2,31 @@
本文件为 Claude Code (claude.ai/code) 在此代码仓库中工作时提供指导。
## 重要开发规范
### 文档同步更新规则
**关键规则**: 当实现新功能、修改现有功能或更改项目配置时,**必须同步更新相关文档**。
- **README.md 更新时机**:
- 添加新的核心功能或特性
- 修改项目安装、配置或运行方式
- 更改技术栈或主要依赖
- 添加新的 API 端点或修改现有端点
- 更新项目架构或目录结构
- **CLAUDE.md 更新时机**:
- 添加新的开发规范或最佳实践
- 修改项目配置(如代理、构建工具等)
- 更改目录结构或文件组织方式
- 引入新的工具或技术
- 更新常用命令或开发流程
- **更新原则**:
- 代码修改和文档更新应在同一次提交中完成
- 文档要清晰、准确,反映当前代码状态
- 使用 markdown 链接引用具体文件位置
- 及时移除过时的说明和示例
## 项目概述
AnKao 是一个使用 **Gin 框架**构建的 Go Web 应用程序。该项目遵循整洁架构模式,具有清晰的关注点分离,并设计为支持数据库集成。
@ -107,6 +132,34 @@ go test -v ./...
## 前端开发规范
### 包管理和开发
**重要**: 前端项目使用 **yarn** 作为包管理工具。
- **包管理器**: 使用 `yarn` 而非 `npm`
- **常用命令**:
```bash
# 安装依赖
yarn install
# 启动开发服务器 (运行在 http://localhost:3000)
yarn dev
# 构建生产版本
yarn build
# 添加依赖
yarn add <package-name>
# 添加开发依赖
yarn add -D <package-name>
```
### API 代理配置
- **开发环境**: Vite 已配置代理,前端请求 `/api/*` 会被代理到后端 `http://localhost:8080`
- **配置位置**: [web/vite.config.ts](web/vite.config.ts)
- **使用方式**: 前端代码中使用相对路径调用API,例如 `fetch('/api/login')`
- **后端服务器**: 确保后端服务运行在 `http://localhost:8080`
### UI 组件使用原则
**重要**: 在开发前端页面时,必须优先使用 UI 框架的组件。
@ -125,4 +178,10 @@ go test -v ./...
- **web/src/pages/** - 页面组件
- **web/src/components/** - 可复用组件
- **web/src/pages/*.module.less** - 页面样式文件 (CSS Modules)
- **web/vite.config.ts** - Vite 配置文件(包含代理配置)
### 移动端适配
- **禁止缩放**: 应用已配置防止移动端双指缩放
- **响应式设计**: 优先考虑移动端布局和交互
- **触摸优化**: 使用合适的触摸区域大小(最小 44x44px)

View File

@ -13,24 +13,54 @@ AnKao/
│ └── models/ # 数据模型
├── pkg/ # 公共库代码
│ └── config/ # 配置管理
├── web/ # 前端项目目录
│ ├── src/ # 源代码
│ │ ├── pages/ # 页面组件
│ │ ├── components/ # 可复用组件
│ │ └── main.tsx # 入口文件
│ ├── vite.config.ts # Vite配置(含API代理)
│ └── package.json # 前端依赖
├── go.mod # Go模块文件
├── CLAUDE.md # 开发规范文档
└── README.md # 项目说明
```
## 快速开始
### 运行服务器
### 后端服务器
```bash
# 安装依赖
go mod tidy
# 运行服务器
go run main.go
```
服务器将在 `http://localhost:8080` 启动
### 前端开发服务器
```bash
# 进入前端目录
cd web
# 安装依赖 (使用 yarn)
yarn install
# 运行开发服务器
yarn dev
```
前端开发服务器将在 `http://localhost:3000` 启动
**注意**: 前端使用 Vite 代理,所有 `/api/*` 请求会自动转发到后端服务器,无需配置 CORS。
### API端点
#### 用户相关
- `POST /api/login` - 用户登录
- `POST /api/register` - 用户注册
#### 题目相关
- `GET /api/questions` - 获取题目列表
@ -100,30 +130,70 @@ go build -o bin/server.exe main.go
## 前端开发
前端项目位于 `web/` 目录,使用 **yarn** 作为包管理工具。
### 安装依赖
```bash
cd web
npm install
yarn install
```
### 运行开发服务器
```bash
npm run dev
yarn dev
```
### 构建
开发服务器运行在 `http://localhost:3000`
### 构建生产版本
```bash
npm run build
yarn build
```
### API代理
开发环境下Vite已配置代理前端 `/api/*` 请求会自动转发到 `http://localhost:8080`无需担心CORS问题。
## 页面结构
- **首页(刷题页)** - 题目练习、随机题目、题目列表、筛选等功能
- **我的** - 用户信息、退出登录
- **登录页** - 用户登录
- **登录页** (`/login`) - 用户登录和注册,支持密码可见性切换
- **首页** (`/`) - 题目练习、随机题目、题目列表、筛选等功能
- **我的** (`/profile`) - 用户信息、退出登录
## 特性
### 后端特性
- 基于 Gin 框架的高性能 HTTP 服务器
- 自定义日志中间件
- RESTful API 结构
- 健康检查端点
- 用户登录和注册系统基于JSON文件存储
- 题目练习功能
- 答题统计功能
### 前端特性
- React + TypeScript + Vite 技术栈
- Ant Design Mobile UI组件库
- 移动端优化,防止双指缩放
- CSS Modules 样式隔离
- 现代化的登录/注册界面
- 密码可见性切换功能
## 技术栈
### 后端
- **Go** 1.25.1
- **Gin** v1.11.0 - Web 框架
### 前端
- **React** 18 - UI框架
- **TypeScript** - 类型安全
- **Vite** - 构建工具
- **Ant Design Mobile** - UI组件库
- **React Router** - 路由管理
- **Less** - CSS预处理器
- **Yarn** - 包管理工具
### 开发工具
- API代理配置Vite
- CSS Modules
- 清晰的项目架构,易于扩展

View File

@ -10,13 +10,39 @@
box-sizing: border-box;
}
html {
// 防止移动端缩放
touch-action: pan-x pan-y;
-ms-touch-action: pan-x pan-y;
}
body {
font-family: @font-family;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: @bg-color;
// 防止移动端缩放
touch-action: pan-x pan-y;
-ms-touch-action: pan-x pan-y;
// 防止用户选择文本(可选,提升体验)
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
min-height: 100vh;
// 防止移动端缩放
touch-action: pan-x pan-y;
-ms-touch-action: pan-x pan-y;
}
// 允许输入框选择文本
input,
textarea {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}

View File

@ -3,6 +3,44 @@ import ReactDOM from 'react-dom/client'
import App from './App'
import './index.less'
// 防止移动端双指缩放
document.addEventListener('gesturestart', function (e) {
e.preventDefault()
})
document.addEventListener('gesturechange', function (e) {
e.preventDefault()
})
document.addEventListener('gestureend', function (e) {
e.preventDefault()
})
// 防止双击缩放
let lastTouchEnd = 0
document.addEventListener(
'touchend',
function (e) {
const now = Date.now()
if (now - lastTouchEnd <= 300) {
e.preventDefault()
}
lastTouchEnd = now
},
false
)
// 防止通过touch事件进行缩放
document.addEventListener(
'touchstart',
function (e) {
if (e.touches.length > 1) {
e.preventDefault()
}
},
{ passive: false }
)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@ -4,116 +4,75 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
max-width: 400px;
margin: 0 auto;
width: 100%;
}
.header {
text-align: center;
margin-bottom: 40px;
}
.subtitle {
font-size: 14px;
color: #888;
margin: 0 0 10px 0;
margin-bottom: 48px;
}
.title {
font-size: 48px;
font-weight: 700;
color: var(--adm-color-primary);
font-size: 56px;
font-weight: 800;
color: #ffffff;
margin: 0 0 12px 0;
letter-spacing: 3px;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
text-align: center;
letter-spacing: 2px;
font-weight: 400;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
max-width: 400px;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 14px;
font-weight: 500;
color: #333;
.inputGroup {
margin-bottom: 24px;
}
input {
padding: 12px 16px;
border: 2px solid #e1e8ed;
border-radius: 8px;
.label {
display: block;
margin-bottom: 10px;
font-size: 15px;
transition: all 0.3s;
font-weight: 600;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.input {
width: 100%;
padding: 14px 16px;
font-size: 15px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.95);
color: #333;
transition: all 0.3s ease;
outline: none;
background: white;
&:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
box-sizing: border-box;
&::placeholder {
color: #aaa;
}
}
}
.error {
padding: 12px;
background-color: #fee;
border: 2px solid #fcc;
border-radius: 8px;
color: #c33;
font-size: 14px;
text-align: center;
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.switchMode {
margin-top: 20px;
text-align: center;
width: 100%;
p {
margin: 0;
font-size: 14px;
color: #666;
}
.linkBtn {
background: none;
border: none;
color: #667eea;
font-weight: 600;
cursor: pointer;
padding: 0 0 0 5px;
transition: color 0.3s;
&:hover:not(:disabled) {
color: #764ba2;
text-decoration: underline;
border-color: rgba(255, 255, 255, 0.6);
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:focus {
border-color: #ffffff;
background: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
&:disabled {
@ -121,4 +80,102 @@
cursor: not-allowed;
}
}
.passwordWrapper {
position: relative;
}
.passwordWrapper .input {
padding-right: 48px;
}
.eyeButton {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
font-size: 20px;
&:hover {
color: #667eea;
}
}
.submitButton {
margin-top: 32px;
height: 52px;
font-size: 17px;
font-weight: 700;
border-radius: 12px;
background: #ffffff;
color: #667eea;
border: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
&:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
}
&:not(:disabled):active {
transform: translateY(0);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
:global {
.adm-button {
background: transparent !important;
border: none !important;
color: #667eea !important;
}
}
}
.switchMode {
margin-top: 32px;
text-align: center;
p {
margin: 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.linkBtn {
background: none;
border: none;
color: #ffffff;
font-weight: 700;
cursor: pointer;
padding: 0 0 0 8px;
transition: all 0.2s ease;
font-size: 15px;
text-decoration: underline;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
&:hover:not(:disabled) {
opacity: 0.85;
transform: scale(1.05);
}
&:active:not(:disabled) {
opacity: 0.7;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Input, Form, Toast } from 'antd-mobile'
import { Button, Toast } from 'antd-mobile'
import { EyeInvisibleOutline, EyeOutline } from 'antd-mobile-icons'
import styles from './Login.module.less'
interface LoginResponse {
@ -20,6 +21,7 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const [isLogin, setIsLogin] = useState(true)
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
// 表单字段
const [username, setUsername] = useState('')
@ -46,7 +48,7 @@ const Login: React.FC = () => {
setLoading(true)
try {
const response = await fetch('http://localhost:8080/api/login', {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -57,7 +59,6 @@ const Login: React.FC = () => {
const data: LoginResponse = await response.json()
if (data.success && data.data) {
// 保存token到localStorage
localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user))
@ -66,7 +67,6 @@ const Login: React.FC = () => {
content: '登录成功',
})
// 跳转到首页
navigate('/')
} else {
Toast.show({
@ -105,7 +105,7 @@ const Login: React.FC = () => {
setLoading(true)
try {
const response = await fetch('http://localhost:8080/api/register', {
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -116,7 +116,6 @@ const Login: React.FC = () => {
const data: LoginResponse = await response.json()
if (data.success && data.data) {
// 保存token到localStorage
localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user))
@ -125,7 +124,6 @@ const Login: React.FC = () => {
content: '注册成功',
})
// 跳转到首页
navigate('/')
} else {
Toast.show({
@ -144,49 +142,68 @@ const Login: React.FC = () => {
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (isLogin) {
handleLogin()
} else {
handleRegister()
}
}
return (
<div className={styles.container}>
<div className={styles.header}>
<p className={styles.subtitle}>使AnKao题库系统</p>
<h1 className={styles.title}>AnKao</h1>
<p className={styles.subtitle}>使</p>
</div>
<Form
layout="vertical"
onFinish={isLogin ? handleLogin : handleRegister}
className={styles.form}
>
<Form.Item label="用户名">
<Input
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<input
type="text"
value={username}
onChange={setUsername}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
disabled={loading}
clearable
className={styles.input}
/>
</Form.Item>
</div>
<Form.Item label="密码">
<Input
type="password"
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<div className={styles.passwordWrapper}>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={setPassword}
onChange={(e) => setPassword(e.target.value)}
placeholder={isLogin ? '请输入密码' : '请输入密码至少6位'}
disabled={loading}
clearable
className={styles.input}
/>
</Form.Item>
<button
type="button"
className={styles.eyeButton}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOutline /> : <EyeInvisibleOutline />}
</button>
</div>
</div>
{!isLogin && (
<Form.Item label="昵称(可选)">
<Input
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<input
type="text"
value={nickname}
onChange={setNickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="请输入昵称"
disabled={loading}
clearable
className={styles.input}
/>
</Form.Item>
</div>
)}
<Button
@ -195,16 +212,18 @@ const Login: React.FC = () => {
block
loading={loading}
disabled={loading}
className={styles.submitButton}
>
{isLogin ? '登录' : '注册'}
</Button>
</Form>
</form>
<div className={styles.switchMode}>
{isLogin ? (
<p>
<button
type="button"
onClick={() => setIsLogin(false)}
className={styles.linkBtn}
disabled={loading}
@ -216,11 +235,12 @@ const Login: React.FC = () => {
<p>
<button
type="button"
onClick={() => setIsLogin(true)}
className={styles.linkBtn}
disabled={loading}
>
</button>
</p>
)}