diff --git a/CLAUDE.md b/CLAUDE.md index da61ff4..3c66509 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + + # 添加开发依赖 + yarn add -D + ``` + +### 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) diff --git a/README.md b/README.md index 62e5496..f3b315a 100644 --- a/README.md +++ b/README.md @@ -13,24 +13,54 @@ AnKao/ │ └── models/ # 数据模型 ├── pkg/ # 公共库代码 │ └── config/ # 配置管理 -├── go.mod # Go模块文件 -└── README.md # 项目说明 +├── 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 - 清晰的项目架构,易于扩展 diff --git a/web/src/index.less b/web/src/index.less index bce03b1..89452c3 100644 --- a/web/src/index.less +++ b/web/src/index.less @@ -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; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 0bb271e..410362f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( diff --git a/web/src/pages/Login.module.less b/web/src/pages/Login.module.less index 31c9af8..e7301e6 100644 --- a/web/src/pages/Login.module.less +++ b/web/src/pages/Login.module.less @@ -4,120 +4,177 @@ 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 { +.inputGroup { + margin-bottom: 24px; +} + +.label { + display: block; + margin-bottom: 10px; + font-size: 15px; + 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; + box-sizing: border-box; + + &::placeholder { + color: #aaa; + } + + &:hover:not(:disabled) { + 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 { + opacity: 0.6; + 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; - flex-direction: column; - gap: 8px; + align-items: center; + justify-content: center; + transition: color 0.2s ease; + font-size: 20px; - label { - font-size: 14px; - font-weight: 500; - color: #333; - } - - input { - padding: 12px 16px; - border: 2px solid #e1e8ed; - border-radius: 8px; - font-size: 15px; - transition: all 0.3s; - outline: none; - background: white; - - &:focus { - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); - } - - &:disabled { - background-color: #f5f5f5; - cursor: not-allowed; - } - - &::placeholder { - color: #aaa; - } + &:hover { + color: #667eea; } } -.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; -} +.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; -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-10px); } - 75% { transform: translateX(10px); } + &: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: 20px; + margin-top: 32px; text-align: center; - width: 100%; p { margin: 0; - font-size: 14px; - color: #666; + 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: #667eea; - font-weight: 600; + color: #ffffff; + font-weight: 700; cursor: pointer; - padding: 0 0 0 5px; - transition: color 0.3s; + 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) { - color: #764ba2; - text-decoration: underline; + opacity: 0.85; + transform: scale(1.05); + } + + &:active:not(:disabled) { + opacity: 0.7; } &:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } } diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index adc011a..3c61600 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -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 (
-

欢迎使用AnKao题库系统

AnKao

+

欢迎使用题库系统

-
- - +
+ + setUsername(e.target.value)} placeholder="请输入用户名" disabled={loading} - clearable + className={styles.input} /> - +
- - - +
+ +
+ setPassword(e.target.value)} + placeholder={isLogin ? '请输入密码' : '请输入密码(至少6位)'} + disabled={loading} + className={styles.input} + /> + +
+
{!isLogin && ( - - + + setNickname(e.target.value)} placeholder="请输入昵称" disabled={loading} - clearable + className={styles.input} /> - +
)} - +
{isLogin ? (

还没有账号?

)}