Merge branch 'light'

This commit is contained in:
张成
2025-09-07 20:13:14 +08:00
19 changed files with 294 additions and 944 deletions

View File

@@ -82,10 +82,11 @@
// 内容区域
.modal_content {
padding: 0px 16px 20px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 20px;
box-sizing: border-box;
.input_container {
display: flex;
@@ -96,7 +97,13 @@
border-radius: 12px;
padding: 10px 16px;
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
min-height: 120px;
// 名字输入时的容器样式
&:has(.nickname_input) {
min-height: 40px;
padding: 10px 16px;
}
.text_input {
flex: 1;
@@ -109,11 +116,21 @@
background: transparent;
outline: none;
resize: none;
min-height: 80px;
min-height: 120px;
&::placeholder {
color: rgba(60, 60, 67, 0.3);
}
// 名字输入特殊样式
&.nickname_input {
min-height: 80px;
min-height: 20px;
height: 20px;
line-height: 20px;
padding: 0;
}
}
.char_count {

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Textarea, Button } from '@tarojs/components';
import { View, Text, Textarea, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './EditModal.scss';
interface EditModalProps {
visible: boolean;
title: string;
type: string;
placeholder: string;
initialValue: string;
maxLength: number;
@@ -17,6 +18,7 @@ interface EditModalProps {
const EditModal: React.FC<EditModalProps> = ({
visible,
title,
type,
placeholder,
initialValue,
maxLength,
@@ -82,17 +84,34 @@ const EditModal: React.FC<EditModalProps> = ({
<View className="modal_content">
{/* 文本输入区域 */}
<View className="input_container">
<Textarea
className="text_input"
{type === 'nickname' ? (
<Input
className="text_input nickname_input"
value={value}
type="nickname"
placeholder={placeholder}
maxlength={maxLength}
onInput={handle_input_change}
adjustPosition={true}
confirmType="done"
autoFocus={true}
/>
<View className="char_count">
<Text className="count_text">{value.length}/{maxLength}</Text>
</View>
) : (
<>
<Textarea
className="text_input"
value={value}
placeholder={placeholder}
maxlength={maxLength}
onInput={handle_input_change}
autoFocus={true}
/>
<View className="char_count">
<Text className="count_text">{value.length}/{maxLength}</Text>
</View>
</>
)}
</View>
{/* 验证提示 */}

View File

@@ -5,7 +5,6 @@
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
// 基本信息
.basic_info {
@@ -220,6 +219,7 @@
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.16);
border-radius: 999px;
box-sizing: border-box;
.tag_icon {
width: 12px;

View File

@@ -15,8 +15,7 @@ export interface UserInfo {
hosted: number;
participated: number;
};
tags: string[];
bio: string;
personal_profile: string;
location: string;
occupation: string;
ntrp_level: string;
@@ -126,21 +125,37 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
{/* 标签和简介 */}
<View className="tags_bio_section">
<View className="tags_container">
<View className="tag_item">
{user_info.gender === "0" && (
<Image
className="tag_icon"
src={require('../../static/userInfo/male.svg')}
/>
)}
{user_info.gender === "1" && (
<Image
className="tag_icon"
src={require('../../static/userInfo/female.svg')}
/>
)}
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.ntrp_level || '未设置'}</Text>
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.occupation || '未设置'}</Text>
</View>
<View className="tag_item">
<Image
className="tag_icon"
src={require('../../static/userInfo/location.svg')}
/>
<Text className="tag_text">{user_info.location}</Text>
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.occupation}</Text>
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.ntrp_level}</Text>
<Text className="tag_text">{user_info.location || '未设置'}</Text>
</View>
</View>
<Text className="bio_text">{user_info.bio}</Text>
<Text className="bio_text">{user_info.personal_profile}</Text>
</View>
</View>
);

View File

@@ -1,7 +1,8 @@
// API配置
import envConfig from './env'// API配置
export const API_CONFIG = {
// 基础URL
BASE_URL: process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://api.example.com',
BASE_URL: envConfig.apiBaseURL,
// 用户相关接口
USER: {

View File

@@ -17,8 +17,8 @@ const envConfigs: Record<EnvType, EnvConfig> = {
// 开发环境
development: {
name: '开发环境',
apiBaseURL: 'https://sit.light120.com',
// apiBaseURL: 'http://localhost:9098',
// apiBaseURL: 'https://sit.light120.com',
apiBaseURL: 'http://localhost:9098',
timeout: 15000,
enableLog: true,
enableMock: true
@@ -27,8 +27,8 @@ const envConfigs: Record<EnvType, EnvConfig> = {
// 测试环境
test: {
name: '测试环境',
apiBaseURL: 'https://sit.light120.com',
// apiBaseURL: 'http://localhost:9098',
// apiBaseURL: 'https://sit.light120.com',
apiBaseURL: 'http://localhost:9098',
timeout: 12000,
enableLog: true,
enableMock: false
@@ -48,18 +48,18 @@ const envConfigs: Record<EnvType, EnvConfig> = {
export const getCurrentEnv = (): EnvType => {
// 在小程序环境中,使用默认逻辑判断环境
// 可以根据实际需要配置不同的判断逻辑
// 可以根据实际部署情况添加更多判断逻辑
// 比如通过 Taro.getEnv() 获取当前平台环境
const currentEnv = Taro.getEnv()
// 在开发调试时,可以通过修改这里的逻辑来切换环境
// 默认在小程序中使用生产环境配置
// if (currentEnv === Taro.ENV_TYPE.WEAPP) {
// // 微信小程序环境
// return 'production'
// }
// 默认返回开发环境(便于调试)
return 'development'
}
@@ -97,9 +97,9 @@ export const getEnvInfo = () => {
env: getCurrentEnv(),
config,
taroEnv: Taro.getEnv(),
platform: Taro.getEnv() === Taro.ENV_TYPE.WEAPP ? '微信小程序' :
Taro.getEnv() === Taro.ENV_TYPE.H5 ? 'H5' :
Taro.getEnv() === Taro.ENV_TYPE.RN ? 'React Native' : '未知'
platform: Taro.getEnv() === Taro.ENV_TYPE.WEAPP ? '微信小程序' :
Taro.getEnv() === Taro.ENV_TYPE.H5 ? 'H5' :
Taro.getEnv() === Taro.ENV_TYPE.RN ? 'React Native' : '未知'
}
}

View File

@@ -50,7 +50,7 @@ const LoginPage: React.FC = () => {
} else {
Taro.redirectTo({ url: '/pages/list/index' });
}
}, 200);
}, 10);
} else {
Taro.showToast({
title: response.message,
@@ -118,9 +118,9 @@ const LoginPage: React.FC = () => {
return (
<View className="login_page">
<View className="background_image">
<Image
<Image
className="bg_img"
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/2e00dea1-8723-42fe-ae42-84fe38e9ac3f.png"
src={require('../../../static/login/login_bg.jpg')}
mode="aspectFill"
/>
<View className="bg_overlay"></View>

View File

@@ -12,7 +12,7 @@ const VerificationPage: React.FC = () => {
const [is_loading, setIsLoading] = useState(false);
const [code_input_focus, setCodeInputFocus] = useState(false);
const { params: { redirect } } = useRouter();
// 计算登录按钮是否应该启用
const can_login = phone.length === 11 && verification_code.length === 6 && !is_loading;
@@ -120,18 +120,15 @@ const VerificationPage: React.FC = () => {
try {
// 调用登录服务
debugger;
const result = await phone_auth_login({ phone, verification_code });
if (result.success) {
setTimeout(() => {
if (redirect) {
Taro.redirectTo({ url: decodeURIComponent(redirect) });
} else {
Taro.redirectTo({
url: '/pages/list/index'
});
}
}, 200);
} else {
Taro.showToast({

View File

@@ -1,211 +0,0 @@
# API接口集成说明
## 已集成的接口
### 1. 用户详情接口 `/user/detail`
**请求方式**: POST
**请求参数**:
```json
{
"user_id": "string" // 可选,不传则获取当前用户信息
}
```
**响应格式**:
```json
{
"code": 0,
"message": "string",
"data": {
"openid": "",
"user_code": "",
"unionid": "",
"session_key": "",
"nickname": "张三",
"avatar_url": "https://example.com/avatar.jpg",
"gender": "",
"country": "",
"province": "",
"city": "",
"language": "",
"phone": "13800138000",
"is_subscribed": "0",
"latitude": "0",
"longitude": "0",
"subscribe_time": "2024-06-15 14:00:00",
"last_login_time": "2024-06-15 14:00:00"
}
}
```
### 2. 用户信息更新接口 `/user/update`
**请求方式**: POST
**请求参数**:
```json
{
"nickname": "string",
"avatar_url": "string",
"gender": "string",
"phone": "string",
"latitude": 31.2304,
"longitude": 121.4737,
"city": "string",
"province": "string",
"country": "string"
}
```
**响应格式**:
```json
{
"code": 0,
"message": "string",
"data": {}
}
```
### 3. 头像上传接口 `/gallery/upload`
**请求方式**: POST (multipart/form-data)
**请求参数**:
- `file`: 图片文件
**响应格式**:
```json
{
"code": 0,
"message": "请求成功!",
"data": {
"create_time": "2025-09-06 19:41:18",
"last_modify_time": "2025-09-06 19:41:18",
"duration": "0",
"thumbnail_url": "",
"view_count": "0",
"download_count": "0",
"is_delete": 0,
"id": 67,
"user_id": 1,
"resource_type": "image",
"file_name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"original_name": "微信图片_20250505175522.jpg",
"file_path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"file_url": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"file_size": 264506,
"mime_type": "image/jpeg",
"description": "用户图像",
"tags": "用户图像",
"is_public": "1",
"width": 0,
"height": 0,
"uploadInfo": {
"success": true,
"name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"ossPath": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"fileType": "image/jpeg",
"fileSize": 264506,
"originalName": "微信图片_20250505175522.jpg",
"suffix": "jpg",
"storagePath": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg"
}
}
}
```
**说明**: 上传成功后,使用 `data.file_url` 字段作为头像URL。
## 使用方式
### 在页面中调用
```typescript
import { UserService } from '@/services/userService';
// 获取用户信息
const userInfo = await UserService.get_user_info('user_id');
// 更新用户信息
await UserService.save_user_info({
nickname: '新昵称',
phone: '13800138000',
gender: '男'
});
// 上传头像
const avatarUrl = await UserService.upload_avatar('/path/to/image.jpg');
```
### API配置
API配置位于 `src/config/api.ts`,可以根据环境自动切换接口地址:
```typescript
export const API_CONFIG = {
BASE_URL: process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: 'https://api.example.com',
// ...
};
```
## 错误处理
所有API调用都包含完整的错误处理
1. **网络错误**: 自动捕获并显示友好提示
2. **业务错误**: 根据返回的 `code``message` 处理
3. **超时处理**: 10秒超时设置
4. **降级处理**: API失败时返回默认数据
## 数据映射
### 用户信息映射
API返回的用户数据会自动映射到前端组件使用的格式
```typescript
// API数据 -> 前端组件数据
{
user_code -> id,
nickname -> nickname,
avatar_url -> avatar,
subscribe_time -> join_date,
city -> location,
// ...
}
```
## 注意事项
1. **位置信息**: 更新用户信息时会自动获取当前位置
2. **头像处理**: 上传失败时自动使用默认头像
3. **表单验证**: 编辑资料页面包含完整的表单验证
4. **类型安全**: 所有接口都有完整的TypeScript类型定义
## 扩展接口
如需添加新的用户相关接口,可以在 `UserService` 中添加新方法:
```typescript
static async new_api_method(params: any): Promise<any> {
try {
const response = await Taro.request({
url: `${API_CONFIG.BASE_URL}/new/endpoint`,
method: 'POST',
data: params,
...REQUEST_CONFIG
});
if (response.data.code === 0) {
return response.data.data;
} else {
throw new Error(response.data.message);
}
} catch (error) {
console.error('API调用失败:', error);
throw error;
}
}
```

View File

@@ -1,240 +0,0 @@
# 头像上传功能说明
## 接口更新
### 新的上传接口 `/gallery/upload`
**接口地址**: `/gallery/upload`
**请求方式**: POST (multipart/form-data)
**功能**: 上传图片文件到阿里云OSS
### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| file | File | 是 | 图片文件 |
### 响应格式
```json
{
"code": 0,
"message": "请求成功!",
"data": {
"create_time": "2025-09-06 19:41:18",
"last_modify_time": "2025-09-06 19:41:18",
"duration": "0",
"thumbnail_url": "",
"view_count": "0",
"download_count": "0",
"is_delete": 0,
"id": 67,
"user_id": 1,
"resource_type": "image",
"file_name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"original_name": "微信图片_20250505175522.jpg",
"file_path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"file_url": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"file_size": 264506,
"mime_type": "image/jpeg",
"description": "用户图像",
"tags": "用户图像",
"is_public": "1",
"width": 0,
"height": 0,
"uploadInfo": {
"success": true,
"name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"ossPath": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
"fileType": "image/jpeg",
"fileSize": 264506,
"originalName": "微信图片_20250505175522.jpg",
"suffix": "jpg",
"storagePath": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg"
}
}
}
```
## 关键字段说明
### 主要字段
- `file_url`: 图片的完整访问URL用于前端显示
- `file_path`: 与file_url相同图片的完整访问URL
- `file_size`: 文件大小(字节)
- `mime_type`: 文件MIME类型
- `original_name`: 原始文件名
### 上传信息字段
- `uploadInfo.success`: 上传是否成功
- `uploadInfo.ossPath`: OSS存储路径
- `uploadInfo.fileType`: 文件类型
- `uploadInfo.fileSize`: 文件大小
- `uploadInfo.suffix`: 文件后缀
## 前端实现
### TypeScript接口定义
```typescript
interface UploadResponseData {
create_time: string;
last_modify_time: string;
duration: string;
thumbnail_url: string;
view_count: string;
download_count: string;
is_delete: number;
id: number;
user_id: number;
resource_type: string;
file_name: string;
original_name: string;
file_path: string;
file_url: string;
file_size: number;
mime_type: string;
description: string;
tags: string;
is_public: string;
width: number;
height: number;
uploadInfo: {
success: boolean;
name: string;
path: string;
ossPath: string;
fileType: string;
fileSize: number;
originalName: string;
suffix: string;
storagePath: string;
};
}
```
### 上传方法实现
```typescript
static async upload_avatar(file_path: string): Promise<string> {
try {
const uploadResponse = await Taro.uploadFile({
url: `${API_CONFIG.BASE_URL}${API_CONFIG.UPLOAD.AVATAR}`,
filePath: file_path,
name: 'file'
});
const result = JSON.parse(uploadResponse.data) as ApiResponse<UploadResponseData>;
if (result.code === 0) {
// 使用file_url字段作为头像URL
return result.data.file_url;
} else {
throw new Error(result.message || '头像上传失败');
}
} catch (error) {
console.error('头像上传失败:', error);
// 上传失败时返回默认头像
return require('../../static/userInfo/default_avatar.svg');
}
}
```
## 使用方式
### 在编辑资料页面中使用
```typescript
// 处理头像上传
const handle_avatar_upload = () => {
Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0];
try {
const avatar_url = await UserService.upload_avatar(tempFilePath);
setUserInfo(prev => ({ ...prev, avatar: avatar_url }));
Taro.showToast({
title: '头像上传成功',
icon: 'success'
});
} catch (error) {
console.error('头像上传失败:', error);
Taro.showToast({
title: '头像上传失败',
icon: 'none'
});
}
}
});
};
```
## 功能特点
### 1. OSS存储
- 图片直接上传到阿里云OSS
- 支持CDN加速访问
- 自动生成唯一文件名
### 2. 文件信息完整
- 记录文件大小、类型、原始名称
- 支持文件描述和标签
- 记录上传时间和修改时间
### 3. 错误处理
- 上传失败时自动使用默认头像
- 完整的错误日志记录
- 用户友好的错误提示
### 4. 类型安全
- 完整的TypeScript类型定义
- 编译时类型检查
- 智能代码提示
## 注意事项
1. **文件大小限制**: 建议限制上传文件大小,避免过大文件
2. **文件类型验证**: 只允许上传图片格式文件
3. **网络处理**: 上传过程中需要处理网络异常情况
4. **用户体验**: 上传过程中显示加载状态
5. **缓存策略**: 上传成功后更新本地缓存
## 扩展功能
### 图片压缩
```typescript
// 可以在上传前进行图片压缩
const compressImage = (filePath: string) => {
return Taro.compressImage({
src: filePath,
quality: 80
});
};
```
### 进度显示
```typescript
// 显示上传进度
const uploadWithProgress = (filePath: string) => {
return Taro.uploadFile({
url: `${API_CONFIG.BASE_URL}${API_CONFIG.UPLOAD.AVATAR}`,
filePath: filePath,
name: 'file',
success: (res) => {
// 处理成功
},
fail: (err) => {
// 处理失败
}
});
};
```
---
**更新时间**: 2024年12月19日
**接口版本**: v1.0
**存储方式**: 阿里云OSS

View File

@@ -1,160 +0,0 @@
# 个人页面API接口集成完成
## ✅ 已完成的工作
### 1. API接口集成
- **用户详情接口** (`/user/detail`) - 获取用户信息
- **用户更新接口** (`/user/update`) - 更新用户详细信息
- **头像上传接口** (`/gallery/upload`) - 上传用户头像到OSS
### 2. 服务层优化
- 创建了 `UserService`统一管理用户相关API调用
- 添加了完整的TypeScript类型定义
- 实现了错误处理和降级机制
- 支持位置信息自动获取
### 3. 配置管理
- 创建了 `API_CONFIG` 配置文件
- 支持开发/生产环境自动切换
- 统一的请求配置和超时设置
### 4. 编辑资料页面增强
- 新增手机号输入字段
- 新增性别选择器(男/女)
- 保留NTRP等级选择器
- 完整的表单验证
### 5. 数据映射
- API数据格式自动映射到前端组件格式
- 支持默认值处理
- 时间格式转换
## 🔧 技术特点
### API调用方式
```typescript
// 获取用户信息
const userInfo = await UserService.get_user_info('user_id');
// 更新用户信息
await UserService.save_user_info({
nickname: '新昵称',
phone: '13800138000',
gender: '男',
location: '上海'
});
// 上传头像
const avatarUrl = await UserService.upload_avatar('/path/to/image.jpg');
```
### 错误处理
- 网络错误自动捕获
- 业务错误友好提示
- API失败时降级到默认数据
- 完整的日志记录
### 类型安全
- 完整的TypeScript接口定义
- API请求/响应类型约束
- 组件属性类型检查
## 📱 功能亮点
### 1. 智能数据获取
- 根据参数自动判断获取当前用户或指定用户信息
- 支持用户ID参数传递
- 自动处理数据格式转换
### 2. 位置服务集成
- 更新用户信息时自动获取当前位置
- 支持经纬度坐标传递
- 城市信息自动填充
### 3. 文件上传优化
- 支持图片压缩上传
- 上传失败时自动使用默认头像
- 进度提示和错误处理
### 4. 表单体验优化
- 实时表单验证
- 字符计数显示
- 选择器交互优化
## 🚀 使用方式
### 页面导航
```typescript
// 访问个人页面
Taro.navigateTo({
url: '/pages/userInfo/myself/index'
});
// 访问他人页面
Taro.navigateTo({
url: `/pages/userInfo/other/index?userid=${user_id}`
});
// 访问编辑资料页面
Taro.navigateTo({
url: '/pages/userInfo/edit/index'
});
```
### API配置
```typescript
// 开发环境
API_CONFIG.BASE_URL = 'http://localhost:3000'
// 生产环境
API_CONFIG.BASE_URL = 'https://api.example.com'
```
## 📋 接口规范
### 请求格式
- 所有接口使用POST方法
- 请求头: `Content-Type: application/json`
- 超时设置: 10秒
### 响应格式
```json
{
"code": 0, // 0表示成功非0表示失败
"message": "string", // 错误信息
"data": {} // 响应数据
}
```
### 错误码处理
- `code: 0` - 请求成功
- `code: 非0` - 业务错误显示message
- 网络错误 - 显示"网络连接失败"
## 🔄 数据流
1. **页面加载** → 调用 `UserService.get_user_info()`
2. **用户操作** → 调用相应的API方法
3. **数据更新** → 自动刷新页面状态
4. **错误处理** → 显示友好提示信息
## 📝 注意事项
1. **权限处理**: 需要确保用户已登录才能调用API
2. **缓存策略**: 建议添加用户信息缓存机制
3. **图片处理**: 头像上传需要后端支持文件上传
4. **位置权限**: 需要用户授权位置信息访问
## 🎯 下一步优化
1. 添加用户信息缓存机制
2. 实现离线数据支持
3. 优化图片上传体验
4. 添加更多用户统计信息接口
5. 实现用户关注/粉丝功能
---
**集成完成时间**: 2024年12月19日
**API版本**: v1.0
**兼容性**: 支持所有Taro框架版本

View File

@@ -1,97 +0,0 @@
# 个人页面功能说明
## 功能概述
个人页面模块包含三个主要功能页面:
1. **个人页面** (`/pages/userInfo/myself/index`) - 当前用户的主页
2. **他人页面** (`/pages/userInfo/other/index`) - 其他用户的主页
3. **编辑资料** (`/pages/userInfo/edit/index`) - 编辑个人资料
## 主要功能
### 个人页面 (myself)
- 显示当前用户的基本信息(头像、昵称、加入时间)
- 显示统计数据(关注、球友、主办、参加)
- 显示个人标签和简介
- 提供编辑和分享功能
- 显示球局订单和收藏快捷入口
- 展示用户主办的球局和参与的球局
### 他人页面 (other)
- 显示其他用户的基本信息
- 提供关注/取消关注功能
- 提供发送消息功能
- 展示该用户主办的球局和参与的球局
- 支持点击参与者头像查看其他用户主页
### 编辑资料 (edit)
- 支持更换头像
- 编辑昵称、个人简介、所在地区、职业
- NTRP等级选择
- 表单验证和保存功能
## 技术特点
### 组件化设计
- `UserInfoCard` - 用户信息卡片组件
- `GameCard` - 球局卡片组件
- `GameTabs` - 球局标签页组件
### 服务层
- `UserService` - 用户相关API服务
- `get_user_info()` - 获取用户信息
- `get_user_games()` - 获取用户球局记录
- `toggle_follow()` - 关注/取消关注
- `save_user_info()` - 保存用户信息
- `upload_avatar()` - 上传头像
### 页面导航
- 支持通过 `userid` 参数区分个人页面和他人页面
- 页面间导航逻辑完善
- 参数传递和状态管理
## 使用方式
### 访问个人页面
```javascript
Taro.navigateTo({
url: '/pages/userInfo/myself/index'
});
```
### 访问他人页面
```javascript
Taro.navigateTo({
url: `/pages/userInfo/other/index?userid=${user_id}`
});
```
### 访问编辑资料页面
```javascript
Taro.navigateTo({
url: '/pages/userInfo/edit/index'
});
```
## 样式特点
- 使用渐变背景设计
- 卡片式布局
- 响应式交互效果
- 统一的视觉风格
- 符合小程序设计规范
## 数据流
1. 页面加载时从 `UserService` 获取数据
2. 用户操作通过回调函数处理
3. 状态更新后重新渲染组件
4. 支持异步操作和错误处理
## 扩展性
- 组件可复用性强
- 服务层易于扩展
- 支持更多用户功能扩展
- 便于维护和测试

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Image, ScrollView, Input } from '@tarojs/components';
import { View, Text, Image, ScrollView, Picker, Input } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
import { UserInfo } from '@/components/UserInfo';
@@ -19,8 +19,7 @@ const EditProfilePage: React.FC = () => {
hosted: 0,
participated: 0
},
tags: ['加载中...'],
bio: '加载中...',
personal_profile: '加载中...',
location: '加载中...',
occupation: '加载中...',
ntrp_level: 'NTRP 3.0',
@@ -31,7 +30,7 @@ const EditProfilePage: React.FC = () => {
// 表单状态
const [form_data, setFormData] = useState({
nickname: '',
bio: '',
personal_profile: '',
location: '',
occupation: '',
ntrp_level: '4.0',
@@ -59,11 +58,11 @@ const EditProfilePage: React.FC = () => {
const user_data = await UserService.get_user_info();
setUserInfo(user_data);
setFormData({
nickname: user_data.nickname,
bio: user_data.bio,
location: user_data.location,
occupation: user_data.occupation,
ntrp_level: user_data.ntrp_level.replace('NTRP ', ''),
nickname: user_data.nickname || '',
personal_profile: user_data.personal_profile || '',
location: user_data.location || '',
occupation: user_data.occupation || '',
ntrp_level: user_data.ntrp_level || 'NTRP 4.0',
phone: user_data.phone || '',
gender: user_data.gender || '',
birthday: '2000-01-01' // 默认生日,实际应该从用户数据获取
@@ -80,17 +79,6 @@ const EditProfilePage: React.FC = () => {
}
};
// 处理输入变化
const handle_input_change = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
// 处理头像上传
const handle_avatar_upload = () => {
Taro.chooseImage({
@@ -119,14 +107,42 @@ const EditProfilePage: React.FC = () => {
// 处理编辑弹窗
const handle_open_edit_modal = (field: string) => {
setEditingField(field);
setEditModalVisible(true);
if (field === 'nickname') {
// 手动输入
setEditingField(field);
setEditModalVisible(true);
} else {
setEditingField(field);
setEditModalVisible(true);
}
};
const handle_edit_modal_save = (value: string) => {
setFormData(prev => ({ ...prev, [editing_field]: value }));
setEditModalVisible(false);
setEditingField('');
const handle_edit_modal_save = async (value: string) => {
try {
// 调用更新用户信息接口,只传递修改的字段
const update_data = { [editing_field]: value };
await UserService.update_user_info(update_data);
// 更新本地状态
setFormData(prev => ({ ...prev, [editing_field]: value }));
setUserInfo(prev => ({ ...prev, [editing_field]: value }));
// 关闭弹窗
setEditModalVisible(false);
setEditingField('');
// 显示成功提示
Taro.showToast({
title: '保存成功',
icon: 'success'
});
} catch (error) {
console.error('保存失败:', error);
Taro.showToast({
title: '保存失败',
icon: 'error'
});
}
};
const handle_edit_modal_cancel = () => {
@@ -134,6 +150,58 @@ const EditProfilePage: React.FC = () => {
setEditingField('');
};
// 处理字段编辑
const handle_field_edit = async (field: string, value: string) => {
try {
// 调用更新用户信息接口,只传递修改的字段
const update_data = { [field]: value };
await UserService.update_user_info(update_data);
// 更新本地状态
setFormData(prev => ({ ...prev, [field]: value }));
setUserInfo(prev => ({ ...prev, [field]: value }));
// 显示成功提示
Taro.showToast({
title: '保存成功',
icon: 'success'
});
} catch (error) {
console.error('保存失败:', error);
Taro.showToast({
title: '保存失败',
icon: 'error'
});
}
};
// 处理性别选择
const handle_gender_change = (e: any) => {
const gender_value = e.detail.value;
const gender_text = gender_value === 'male' ? '男' : '女';
handle_field_edit('gender', gender_text);
};
// 处理生日选择
const handle_birthday_change = (e: any) => {
const birthday_value = e.detail.value;
handle_field_edit('birthday', birthday_value);
};
// 处理职业输入
const handle_occupation_change = (e: any) => {
const occupation_value = e.detail.value;
handle_field_edit('occupation', occupation_value);
};
// 处理地区输入
const handle_location_change = (e: any) => {
const location_value = e.detail.value;
handle_field_edit('location', location_value);
};
// 处理退出登录
const handle_logout = () => {
Taro.showModal({
@@ -183,18 +251,13 @@ const EditProfilePage: React.FC = () => {
<View className="form_section">
{/* 名字 */}
<View className="form_group">
<View className="form_item">
<View className="form_item" onClick={() => handle_open_edit_modal('nickname')}>
<View className="item_left">
<Image className="item_icon" src={require('../../../static/userInfo/user1.svg')} />
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Input
className="item_input"
value={form_data.nickname}
placeholder="188的王晨"
onInput={(e) => handle_input_change('nickname', e.detail.value)}
/>
<Text className="item_value">{form_data.nickname || '188的王晨'}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
</View>
</View>
@@ -203,45 +266,58 @@ const EditProfilePage: React.FC = () => {
{/* 性别 */}
<View className="form_group">
<View className="form_item">
<View className="item_left">
<Image className="item_icon" src={require('../../../static/userInfo/user2.svg')} />
<Text className="item_label"></Text>
<Picker
mode="selector"
range={['男', '女']}
value={form_data.gender === '男' ? 0 : 1}
onChange={handle_gender_change}
>
<View className="form_item">
<View className="item_left">
<Image className="item_icon" src={require('../../../static/userInfo/user2.svg')} />
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Text className="item_value">{form_data.gender || '请选择'}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
</View>
</View>
<View className="item_right">
<Text className="item_value">{form_data.gender || '男'}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
</View>
</View>
</Picker>
<View className="divider"></View>
</View>
{/* 生日 */}
<View className="form_group">
<View className="form_item">
<View className="item_left">
<Image className="item_icon" src={require('../../../static/userInfo/tennis.svg')} />
<Text className="item_label"></Text>
<Picker
mode="date"
value={form_data.birthday}
onChange={handle_birthday_change}
>
<View className="form_item">
<View className="item_left">
<Image className="item_icon" src={require('../../../static/userInfo/tennis.svg')} />
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Text className="item_value">{form_data.birthday}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
</View>
</View>
<View className="item_right">
<Text className="item_value">{form_data.birthday}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
</View>
</View>
</Picker>
</View>
</View>
{/* 简介编辑 */}
<View className="form_section">
<View className="form_group">
<View className="form_item" onClick={() => handle_open_edit_modal('bio')}>
<View className="form_item" onClick={() => handle_open_edit_modal('personal_profile')}>
<View className="item_left">
<Image className="item_icon" src={require('../../../static/userInfo/message.svg')} />
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Text className="item_value">
{form_data.bio || '介绍一下自己'}
{form_data.personal_profile || '介绍一下自己'}
</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
</View>
@@ -259,8 +335,12 @@ const EditProfilePage: React.FC = () => {
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Text className="item_value">{form_data.location || '上海 黄浦'}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
<Input
className="item_input"
value={form_data.location}
placeholder="请输入地区"
onBlur={(e) => handle_location_change(e)}
/>
</View>
</View>
<View className="divider"></View>
@@ -285,8 +365,12 @@ const EditProfilePage: React.FC = () => {
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Text className="item_value">{form_data.occupation || '互联网'}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
<Input
className="item_input"
value={form_data.occupation}
placeholder="请输入职业"
onBlur={(e) => handle_occupation_change(e)}
/>
</View>
</View>
</View>
@@ -301,8 +385,13 @@ const EditProfilePage: React.FC = () => {
<Text className="item_label"></Text>
</View>
<View className="item_right">
<Text className="item_value">{form_data.phone || '+86 130 1234 1234'}</Text>
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
<Input
className="item_input"
value={form_data.phone}
placeholder="请输入手机号"
type="number"
onBlur={(e) => handle_field_edit('phone', e.detail.value)}
/>
</View>
</View>
<View className="divider"></View>
@@ -322,13 +411,14 @@ const EditProfilePage: React.FC = () => {
{/* 编辑弹窗 */}
<EditModal
visible={edit_modal_visible}
title="编辑简介"
placeholder="介绍一下你的喜好,或者训练习惯"
type={editing_field}
title={editing_field === 'nickname' ? '编辑名字' : '编辑简介'}
placeholder={editing_field === 'nickname' ? '请输入您的名字' : '介绍一下你的喜好,或者训练习惯'}
initialValue={form_data[editing_field as keyof typeof form_data] || ''}
maxLength={100}
maxLength={editing_field === 'nickname' ? 20 : 100}
onSave={handle_edit_modal_save}
onCancel={handle_edit_modal_cancel}
validationMessage="请填写 2-100 个字符"
validationMessage={editing_field === 'nickname' ? '请填写 1-20 个字符' : '请填写 2-100 个字符'}
/>
</View>
);

View File

@@ -17,14 +17,13 @@
margin-top: 0;
box-sizing: border-box;
overflow-y: auto;
padding: 15px 15px 15px;
padding: 0px 15px 15px 15px ;
// 用户信息区域
.user_info_section {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
margin-top: 98px;
// 加载状态
@@ -146,7 +145,6 @@
// 球局类型标签页
.game_tabs_section {
margin-bottom: 16px;
.tab_container {
display: flex;

View File

@@ -12,7 +12,7 @@ import { withAuth } from '@/components';
const MyselfPage: React.FC = () => {
// 获取页面参数
const instance = Taro.getCurrentInstance();
const user_id = instance.router?.params?.userid;
const user_id = instance.router?.params?.userid || '';
// 判断是否为当前用户
const is_current_user = !user_id;
@@ -29,7 +29,6 @@ const MyselfPage: React.FC = () => {
hosted: 0,
participated: 0
},
tags: ['加载中...'],
bio: '加载中...',
location: '加载中...',
occupation: '加载中...',
@@ -58,9 +57,9 @@ const MyselfPage: React.FC = () => {
// 获取球局记录
let games_data;
if (active_tab === 'hosted') {
games_data = await UserService.get_hosted_games(user_id || '1');
games_data = await UserService.get_hosted_games(user_id);
} else {
games_data = await UserService.get_participated_games(user_id || '1');
games_data = await UserService.get_participated_games(user_id);
}
set_game_records(games_data);
@@ -93,9 +92,9 @@ const MyselfPage: React.FC = () => {
try {
let games_data;
if (active_tab === 'hosted') {
games_data = await UserService.get_hosted_games(user_id || '1');
games_data = await UserService.get_hosted_games(user_id);
} else {
games_data = await UserService.get_participated_games(user_id || '1');
games_data = await UserService.get_participated_games(user_id);
}
set_game_records(games_data);
} catch (error) {
@@ -106,7 +105,7 @@ const MyselfPage: React.FC = () => {
// 处理关注/取消关注
const handle_follow = async () => {
try {
const new_following_state = await UserService.toggle_follow(user_id || '1', is_following);
const new_following_state = await UserService.toggle_follow(user_id, is_following);
setIsFollowing(new_following_state);
Taro.showToast({

View File

@@ -247,32 +247,28 @@ export class UserService {
static async get_user_info(user_id?: string): Promise<UserInfo> {
try {
const response = await httpService.post<UserDetailData>(API_CONFIG.USER.DETAIL, user_id ? { user_id } : {}, {
needAuth: false,
showLoading: false
});
if (response.code === 0) {
const userData = response.data;
return {
id: userData.user_code || user_id || '1',
nickname: userData.nickname || '用户',
avatar: userData.avatar_url || require('../static/userInfo/default_avatar.svg'),
join_date: userData.subscribe_time ? `${new Date(userData.subscribe_time).getFullYear()}${new Date(userData.subscribe_time).getMonth() + 1}月加入` : '未知时间加入',
id: userData.user_code || user_id || '',
nickname: userData.nickname || '',
avatar: userData.avatar_url || '',
join_date: userData.subscribe_time ? `${new Date(userData.subscribe_time).getFullYear()}${new Date(userData.subscribe_time).getMonth() + 1}月加入` : '',
stats: {
following: userData.stats?.following_count || 0,
friends: userData.stats?.followers_count || 0,
hosted: userData.stats?.hosted_games_count || 0,
participated: userData.stats?.participated_games_count || 0
},
tags: [
userData.city || '未知地区',
userData.province || '未知省份',
'NTRP 3.0' // 默认等级,需要其他接口获取
],
bio: '这个人很懒,什么都没有写...',
location: userData.city || '未知地区',
occupation: '未知职业', // 需要其他接口获取
ntrp_level: 'NTRP 3.0', // 需要其他接口获取
personal_profile: '',
location:userData.province + userData.city || '',
occupation: '',
ntrp_level: '',
phone: userData.phone || '',
gender: userData.gender || ''
};
@@ -282,25 +278,23 @@ export class UserService {
} catch (error) {
console.error('获取用户信息失败:', error);
// 返回默认用户信息
return {
id: user_id || '1',
nickname: '用户',
avatar: require('../static/userInfo/default_avatar.svg'),
join_date: '未知时间加入',
stats: {
following: 0,
friends: 0,
hosted: 0,
participated: 0
},
tags: ['未知地区', '未知职业', 'NTRP 3.0'],
bio: '这个人很懒,什么都没有写...',
location: '未知地区',
occupation: '未知职业',
ntrp_level: 'NTRP 3.0',
phone: '',
gender: ''
};
return {} as UserInfo
}
}
// 更新用户信息
static async update_user_info(update_data: Partial<UserInfo>): Promise<void> {
try {
const response = await httpService.post(API_CONFIG.USER.UPDATE, update_data, {
showLoading: true
});
if (response.code !== 0) {
throw new Error(response.message || '更新用户信息失败');
}
} catch (error) {
console.error('更新用户信息失败:', error);
throw error;
}
}
@@ -310,7 +304,7 @@ export class UserService {
const response = await httpService.post<any>(API_CONFIG.USER.HOSTED_GAMES, {
user_id
}, {
needAuth: false,
showLoading: false
});
@@ -323,41 +317,7 @@ export class UserService {
} catch (error) {
console.error('获取主办球局失败:', error);
// 返回符合ListContainer data格式的模拟数据
return [
{
id: 1,
title: '女生轻松双打',
dateTime: '明天(周五) 下午5点',
location: '仁恒河滨花园网球场',
distance: '3.5km',
registeredCount: 2,
maxCount: 4,
skillLevel: '2.0-2.5',
matchType: '双打',
images: [
require('../static/userInfo/game1.svg'),
require('../static/userInfo/game2.svg'),
require('../static/userInfo/game3.svg')
],
shinei: '室外'
},
{
id: 5,
title: '新手友好局',
dateTime: '周日 下午2点',
location: '徐汇网球中心',
distance: '1.8km',
registeredCount: 4,
maxCount: 6,
skillLevel: '1.5-2.0',
matchType: '双打',
images: [
require('../static/userInfo/game1.svg'),
require('../static/userInfo/game2.svg')
],
shinei: '室外'
}
];
return []
}
}
@@ -367,7 +327,7 @@ export class UserService {
const response = await httpService.post<any>(API_CONFIG.USER.PARTICIPATED_GAMES, {
user_id
}, {
needAuth: false,
showLoading: false
});
@@ -380,56 +340,8 @@ export class UserService {
} catch (error) {
console.error('获取参与球局失败:', error);
// 返回符合ListContainer data格式的模拟数据
return [
{
id: 2,
title: '周末双打练习',
dateTime: '后天(周六) 上午10点',
location: '上海网球中心',
distance: '5.2km',
registeredCount: 6,
maxCount: 8,
skillLevel: '3.0-3.5',
matchType: '双打',
images: [
require('../static/userInfo/game2.svg'),
require('../static/userInfo/game3.svg')
],
shinei: '室内'
},
{
id: 3,
title: '晨练单打',
dateTime: '明天(周五) 早上7点',
location: '浦东网球俱乐部',
distance: '2.8km',
registeredCount: 1,
maxCount: 2,
skillLevel: '2.5-3.0',
matchType: '单打',
images: [
require('../static/userInfo/game1.svg')
],
shinei: '室外'
},
{
id: 4,
title: '夜场混双',
dateTime: '今晚 晚上8点',
location: '虹桥网球中心',
distance: '4.1km',
registeredCount: 3,
maxCount: 4,
skillLevel: '3.5-4.0',
matchType: '混双',
images: [
require('../static/userInfo/game1.svg'),
require('../static/userInfo/game2.svg'),
require('../static/userInfo/game3.svg')
],
shinei: '室内'
}
];
return [];
}
}
@@ -447,7 +359,7 @@ export class UserService {
try {
const endpoint = is_following ? API_CONFIG.USER.UNFOLLOW : API_CONFIG.USER.FOLLOW;
const response = await httpService.post<any>(endpoint, { user_id }, {
needAuth: false,
showLoading: true,
loadingText: is_following ? '取消关注中...' : '关注中...'
});
@@ -484,7 +396,7 @@ export class UserService {
};
const response = await httpService.post<any>(API_CONFIG.USER.UPDATE, updateParams, {
needAuth: false,
showLoading: true,
loadingText: '保存中...'
});
@@ -508,7 +420,7 @@ export class UserService {
page,
limit
}, {
needAuth: false,
showLoading: false
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,5 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4879 3.89007V1.64008H8.23792" stroke="#FF69B4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.60338 9.62816C3.9702 10.995 6.18628 10.995 7.55313 9.62816C8.23655 8.94476 8.57825 8.04901 8.57825 7.15328C8.57825 6.25756 8.23655 5.36183 7.55313 4.67841C6.18628 3.31158 3.9702 3.31158 2.60338 4.67841C1.23654 6.04526 1.23654 8.26133 2.60338 9.62816Z" stroke="#FF69B4" stroke-linejoin="round"/>
<path d="M7.5 4.62796L9.98787 2.14008" stroke="#FF69B4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 624 B

View File

@@ -0,0 +1,5 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4879 3.89007V1.64008H8.23792" stroke="#4169E1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.60338 9.62816C3.9702 10.995 6.18628 10.995 7.55313 9.62816C8.23655 8.94476 8.57825 8.04901 8.57825 7.15328C8.57825 6.25756 8.23655 5.36183 7.55313 4.67841C6.18628 3.31158 3.9702 3.31158 2.60338 4.67841C1.23654 6.04526 1.23654 8.26133 2.60338 9.62816Z" stroke="#4169E1" stroke-linejoin="round"/>
<path d="M7.5 4.62796L9.98787 2.14008" stroke="#4169E1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 624 B