初始化工程

This commit is contained in:
筱野
2025-08-10 21:09:19 +08:00
commit 42c71f5d13
30 changed files with 12882 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

7
.eslintrc Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
"extends": ["taro/react"],
"rules": {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off"
}
}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
dist/
deploy_versions/
.temp/
.rn_temp/
node_modules/
.DS_Store
.swc

212
README.md Normal file
View File

@@ -0,0 +1,212 @@
# PlayBallTogether - 约球应用
## 项目简介
这是一个基于Taro + React + TypeScript的约球应用用户可以发布个人约球活动。
## 主要功能
### 核心功能特性
-**图片上传** - 支持上传多张活动封面图片
-**活动信息填写** - 主题、时间、场地、价格等完整信息
-**多玩法选择** - 单打、双打、娱乐拉球、到了再说
-**人数控制** - 灵活设置最少到最多参与人数
-**NTRP水平设置** - 滑动条选择技能水平区间
-**性别偏好** - 支持性别限制或不限
-**自动候补** - 开启自动候补逻辑
-**多场发布** - 支持同时创建多场活动
### 技术架构特性 ⭐
-**动态表单配置** - 通过JSON配置动态生成表单
-**组件化架构** - 可复用的字段渲染器
-**表单验证** - 自动数据验证和错误提示
-**API集中管理** - 统一services目录按页面功能命名
-**Token自动管理** - 自动携带token处理过期刷新
-**请求拦截处理** - 统一错误处理和加载状态
-**多人协作友好** - 按页面隔离,避免代码冲突
## 技术栈
- **框架**: Taro 4.1.5
- **前端**: React 18 + TypeScript
- **UI库**: NutUI React Taro
- **样式**: Sass
- **状态管理**: React Context + useReducer
- **本地存储**: Taro Storage API
## 快速开始
```bash
# 安装依赖
npm install
# 或
yarn install
# 开发环境启动
npm run dev:h5 # H5版本
npm run dev:weapp # 微信小程序
npm run dev:alipay # 支付宝小程序
# 构建生产版本
npm run build:h5 # H5版本
npm run build:weapp # 微信小程序
npm run build:alipay # 支付宝小程序
```
## 页面路由
- `/pages/index/index` - 首页
- `/pages/publish/publish` - 原发布页面
- `/pages/publishBall/publishBall` - 新的约球发布页面 ⭐
- `/pages/dynamicFormDemo/dynamicFormDemo` - 动态表单演示页面 ⭐
## 项目结构 🏗️
本项目采用**按页面分层、组件共享**的架构设计,便于多人协作开发。
```
src/
├── components/ # 🔥 公共组件(所有页面共享)
│ └── DynamicForm/ # 动态表单组件 ⭐
│ ├── DynamicForm.tsx # 动态表单容器
│ ├── DynamicForm.scss # 动态表单样式
│ ├── FieldRenderer.tsx # 字段渲染器
│ ├── FieldRenderer.scss # 字段渲染器样式
│ └── index.ts # 组件导出
├── pages/ # 📱 页面目录(按功能模块划分)
│ ├── index/ # 首页模块
│ ├── publish/ # 原发布页面
│ ├── publishBall/ # 约球发布模块 ⭐
│ │ ├── publishBall.tsx
│ │ ├── publishBall.scss
│ │ └── publishBall.config.ts
│ └── dynamicFormDemo/ # 动态表单演示模块 ⭐
│ ├── dynamicFormDemo.tsx
│ ├── dynamicFormDemo.scss
│ └── dynamicFormDemo.config.ts
├── services/ # 🌐 API服务统一管理
│ ├── httpService.ts # HTTP基础服务 ⭐
│ ├── authService.ts # 认证服务 ⭐
│ ├── commonApi.ts # 通用API服务 ⭐
│ ├── publishBallApi.ts # 🔥 约球发布API服务
│ └── demoApi.ts # 🔥 演示页面API服务
├── utils/ # 🛠️ 工具函数
│ ├── tokenManager.ts # Token管理 ⭐
│ └── storage.ts # 本地存储
├── config/ # ⚙️ 配置文件
│ ├── formConfig.ts # 表单配置定义 ⭐
│ └── exampleConfigs.ts # 示例配置 ⭐
├── store/ # 📦 全局状态管理
│ ├── matchStore.ts # Zustand状态管理
│ └── storage.ts # 本地存储工具
├── app.ts # 应用入口
├── app.config.ts # 应用配置
└── app.scss # 全局样式
```
详细架构说明请查看:[ARCHITECTURE.md](./ARCHITECTURE.md)
## 设计特色
- 🎨 **严格按照设计图实现** - 像素级还原UI设计
- 📱 **移动端优先** - 针对手机屏幕优化
-**流畅交互** - 高性能的用户体验
- 💾 **数据持久化** - 自动保存用户输入
- 🔄 **状态同步** - 全局状态实时更新
## 核心功能实现
### 动态表单配置 ⭐
- **JSON驱动** - 通过配置文件定义表单结构
- **字段类型丰富** - 支持10+种字段类型(图片上传、文本输入、多选、计数器、滑动条等)
- **灵活配置** - 支持验证规则、默认值、选项配置
- **自动渲染** - 根据配置自动生成对应的UI组件
- **类型安全** - 完整的TypeScript类型定义
### 支持的字段类型
- `image-upload` - 图片上传
- `text-input` - 文本输入
- `number-input` - 数字输入
- `time-display` - 时间显示
- `venue-input` - 场地输入(带箭头)
- `multi-select` - 多选按钮
- `counter` - 计数器min/max
- `slider` - 滑动条
- `radio-group` - 单选按钮组
- `checkbox` - 复选框
### 图片上传
- 使用Taro.chooseImage API
- 支持相册和相机选择
- 最多支持9张图片
- 实时预览和删除功能
### 表单验证
- 必填字段验证
- 数字范围验证
- 自定义验证规则
- 友好的错误提示
### 响应式设计
- 适配不同屏幕尺寸
- 灵活的布局系统
- 优雅的动画过渡
## 如何自定义表单配置
### 修改表单字段
编辑 `src/config/formConfig.ts` 文件:
```typescript
// 添加新字段
{
key: 'newField', // 字段唯一标识
type: 'text-input', // 字段类型
title: '新字段', // 显示标题
required: true, // 是否必填
placeholder: '请输入内容', // 占位符
defaultValue: '', // 默认值
validation: { // 验证规则
min: 1,
max: 100,
message: '请输入1-100字符'
}
}
// 修改现有字段
{
key: 'playStyle',
type: 'multi-select',
title: '活动玩法',
options: [
{ label: '新玩法', value: 'new-style' }, // 添加新选项
{ label: '单打', value: '单打' },
// ... 其他选项
]
}
```
### 支持的配置选项
- **基础配置**: `key`, `type`, `title`, `required`, `placeholder`, `hint`
- **默认值**: `defaultValue`
- **验证规则**: `validation.min`, `validation.max`, `validation.message`
- **选项配置**: `options` (用于选择类型字段)
- **特殊配置**: `config.maxImages`, `config.minValue`, `config.showArrow`
## 开发者说明
本项目采用最简单有效、优雅的方式实现,避免代码冗余,遵循以下原则:
1. **组件化设计** - 每个功能模块独立封装
2. **配置驱动** - 通过JSON配置实现灵活的表单生成 ⭐
3. **类型安全** - 完整的TypeScript类型定义
4. **用户体验** - 流畅的交互和反馈
5. **代码质量** - 简洁、可维护的代码结构
## License
MIT

24
babel.config.js Normal file
View File

@@ -0,0 +1,24 @@
// babel-preset-taro 更多选项和默认值:
// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
module.exports = {
presets: [
['taro',
{
framework: 'react',
ts: 'true',
compiler: 'webpack5',
}]
],
plugins: [
[
"import",
{
"libraryName": "@nutui/nutui-react-taro",
"libraryDirectory": "dist/esm",
"style": 'css',
"camel2DashComponentName": false
},
'nutui-react-taro'
]
]
}

23
config/dev.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { UserConfigExport } from "@tarojs/cli";
export default {
logger: {
quiet: false,
stats: true
},
mini: {},
h5: {},
// 添加这个配置来显示完整错误信息
compiler: {
type: 'webpack5',
prebundle: {
enable: false
}
},
cache: {
enable: false // 禁用缓存确保重新编译
},
// 开发环境不压缩代码
terser: {
enable: false
}
} satisfies UserConfigExport<'webpack5'>

104
config/index.ts Normal file
View File

@@ -0,0 +1,104 @@
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'
import devConfig from './dev'
import prodConfig from './prod'
import vitePluginImp from 'vite-plugin-imp'
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
const baseConfig: UserConfigExport<'webpack5'> = {
projectName: 'playBallTogether',
date: '2025-8-9',
designWidth: 375,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-html'],
defineConstants: {
},
copy: {
patterns: [
],
options: {
}
},
framework: 'react',
compiler: {
type: 'webpack5',
prebundle: {
enable: false
}
},
cache: {
enable: false // Webpack 持久化缓存配置建议开启。默认配置请参考https://docs.taro.zone/docs/config-detail#cache
},
mini: {
postcss: {
pxtransform: {
enable: true,
config: {
selectorBlackList: ['nut-']
}
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
webpackChain(chain) {
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin)
}
},
h5: {
publicPath: '/',
staticDirectory: 'static',
output: {
filename: 'js/[name].[hash:8].js',
chunkFilename: 'js/[name].[chunkhash:8].js'
},
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: 'css/[name].[hash].css',
chunkFilename: 'css/[name].[chunkhash].css'
},
postcss: {
autoprefixer: {
enable: true,
config: {}
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
webpackChain(chain) {
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin)
}
},
rn: {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
}
}
}
}
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig)
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig)
})

32
config/prod.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { UserConfigExport } from "@tarojs/cli";
export default {
mini: {},
h5: {
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
}
} satisfies UserConfigExport<'webpack5'>

88
package.json Normal file
View File

@@ -0,0 +1,88 @@
{
"name": "playBallTogether",
"version": "1.0.0",
"private": true,
"description": "playBallTogether",
"templateInfo": {
"name": "react-NutUI",
"typescript": true,
"css": "Sass",
"framework": "React"
},
"scripts": {
"build:weapp": "taro build --type weapp",
"build:swan": "taro build --type swan",
"build:alipay": "taro build --type alipay",
"build:tt": "taro build --type tt",
"build:h5": "taro build --type h5",
"build:rn": "taro build --type rn",
"build:qq": "taro build --type qq",
"build:jd": "taro build --type jd",
"build:quickapp": "taro build --type quickapp",
"dev:weapp": "npm run build:weapp -- --watch",
"dev:swan": "npm run build:swan -- --watch",
"dev:alipay": "npm run build:alipay -- --watch",
"dev:tt": "npm run build:tt -- --watch",
"dev:h5": "npm run build:h5 -- --watch",
"dev:rn": "npm run build:rn -- --watch",
"dev:qq": "npm run build:qq -- --watch",
"dev:jd": "npm run build:jd -- --watch",
"dev:quickapp": "npm run build:quickapp -- --watch"
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
],
"author": "",
"dependencies": {
"@babel/runtime": "^7.21.5",
"@nutui/nutui-react-taro": "^2.6.14",
"@tarojs/components": "4.1.5",
"@tarojs/helper": "4.1.5",
"@tarojs/plugin-framework-react": "4.1.5",
"@tarojs/plugin-html": "4.1.5",
"@tarojs/plugin-platform-alipay": "4.1.5",
"@tarojs/plugin-platform-h5": "4.1.5",
"@tarojs/plugin-platform-jd": "4.1.5",
"@tarojs/plugin-platform-qq": "4.1.5",
"@tarojs/plugin-platform-swan": "4.1.5",
"@tarojs/plugin-platform-tt": "4.1.5",
"@tarojs/plugin-platform-weapp": "4.1.5",
"@tarojs/react": "4.1.5",
"@tarojs/runtime": "4.1.5",
"@tarojs/shared": "4.1.5",
"@tarojs/taro": "4.1.5",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/core": "^7.8.0",
"@babel/plugin-proposal-class-properties": "7.14.5",
"@babel/preset-react": "^7.24.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
"@tarojs/cli": "4.1.5",
"@tarojs/taro-loader": "4.1.5",
"@tarojs/webpack5-runner": "4.1.5",
"@types/node": "^18.15.11",
"@types/react": "^18.0.0",
"@types/webpack-env": "^1.13.6",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"babel-plugin-import": "^1.13.8",
"babel-preset-taro": "4.1.5",
"eslint": "^8.12.0",
"eslint-config-taro": "4.1.5",
"eslint-plugin-import": "^2.12.0",
"eslint-plugin-react": "^7.8.2",
"eslint-plugin-react-hooks": "^4.2.0",
"postcss": "^8.4.18",
"react-refresh": "^0.11.0",
"stylelint": "^14.4.0",
"ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.0.1",
"typescript": "^5.1.0",
"webpack": "5.78.0"
}
}

25
project.config.json Normal file
View File

@@ -0,0 +1,25 @@
{
"miniprogramRoot": "dist/",
"projectname": "playBallTogether",
"description": "playBallTogether",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"preloadBackgroundData": false,
"minified": false,
"newFeature": true,
"autoAudits": false,
"coverView": true,
"showShadowRootInWxmlPanel": false,
"scopeDataCheck": false,
"useCompilerModule": false
},
"compileType": "miniprogram",
"simulatorType": "wechat",
"simulatorPluginLibVersion": {},
"condition": {}
}

13
project.tt.json Normal file
View File

@@ -0,0 +1,13 @@
{
"miniprogramRoot": "./",
"projectname": "playBallTogether",
"description": "playBallTogether",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}

11
src/app.config.ts Normal file
View File

@@ -0,0 +1,11 @@
export default defineAppConfig({
pages: [
'pages/index/index'
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
}
})

0
src/app.scss Normal file
View File

19
src/app.ts Normal file
View File

@@ -0,0 +1,19 @@
import React, { useEffect } from 'react'
import { useDidShow, useDidHide } from '@tarojs/taro'
// 全局样式
import './app.scss'
function App(props: any) {
// 可以使用所有的 React Hooks
useEffect(() => {})
// 对应 onShow
useDidShow(() => {})
// 对应 onHide
useDidHide(() => {})
return props.children
}
export default App

109
src/config/env.ts Normal file
View File

@@ -0,0 +1,109 @@
import Taro from '@tarojs/taro'
// 环境类型
export type EnvType = 'development' | 'test' | 'production'
// 环境配置接口
export interface EnvConfig {
name: string
apiBaseURL: string
apiVersion: string
timeout: number
enableLog: boolean
enableMock: boolean
}
// 各环境配置
const envConfigs: Record<EnvType, EnvConfig> = {
// 开发环境
development: {
name: '开发环境',
apiBaseURL: 'https://dev-api.playballtogether.com',
apiVersion: 'v1',
timeout: 15000,
enableLog: true,
enableMock: true
},
// 测试环境
test: {
name: '测试环境',
apiBaseURL: 'https://test-api.playballtogether.com',
apiVersion: 'v1',
timeout: 12000,
enableLog: true,
enableMock: false
},
// 生产环境
production: {
name: '生产环境',
apiBaseURL: 'https://api.playballtogether.com',
apiVersion: 'v1',
timeout: 10000,
enableLog: false,
enableMock: false
}
}
// 获取当前环境
export const getCurrentEnv = (): EnvType => {
// 在小程序环境中,使用默认逻辑判断环境
// 可以根据实际需要配置不同的判断逻辑
// 可以根据实际部署情况添加更多判断逻辑
// 比如通过 Taro.getEnv() 获取当前平台环境
const currentEnv = Taro.getEnv()
// 在开发调试时,可以通过修改这里的逻辑来切换环境
// 默认在小程序中使用生产环境配置
if (currentEnv === Taro.ENV_TYPE.WEAPP) {
// 微信小程序环境
return 'production'
}
// 默认返回开发环境(便于调试)
return 'development'
}
// 获取当前环境配置
export const getCurrentConfig = (): EnvConfig => {
const env = getCurrentEnv()
return envConfigs[env]
}
// 获取指定环境配置
export const getEnvConfig = (env: EnvType): EnvConfig => {
return envConfigs[env]
}
// 是否为开发环境
export const isDevelopment = (): boolean => {
return getCurrentEnv() === 'development'
}
// 是否为生产环境
export const isProduction = (): boolean => {
return getCurrentEnv() === 'production'
}
// 是否为测试环境
export const isTest = (): boolean => {
return getCurrentEnv() === 'test'
}
// 环境配置调试信息
export const getEnvInfo = () => {
const config = getCurrentConfig()
return {
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' : '未知'
}
}
// 导出当前环境配置(方便直接使用)
export default getCurrentConfig()

17
src/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no,address=no">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
<title>playBallTogether</title>
<script><%= htmlWebpackPlugin.options.script %></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})

261
src/pages/index/index.scss Normal file
View File

@@ -0,0 +1,261 @@
.index-page {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
.page-header {
text-align: center;
margin-bottom: 24px;
.page-title {
font-size: 28px;
font-weight: bold;
color: white;
display: block;
margin-bottom: 8px;
}
.page-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
display: block;
}
}
.user-card {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.user-header {
display: flex;
align-items: center;
gap: 16px;
.user-info {
flex: 1;
.username {
font-size: 20px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 4px;
}
.user-level {
font-size: 14px;
color: #666;
display: block;
margin-bottom: 4px;
}
.join-date {
font-size: 12px;
color: #999;
display: block;
}
}
}
}
.stats-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
display: block;
}
:global {
.nut-cell {
background: #f8f9fa;
border-radius: 12px;
margin-bottom: 8px;
border: none;
&:last-child {
margin-bottom: 0;
}
.nut-cell__title {
font-weight: 500;
color: #555;
}
.nut-cell__value {
font-weight: bold;
color: #007bff;
}
}
}
}
.action-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
display: block;
}
.button-group {
display: flex;
flex-direction: column;
gap: 12px;
.custom-button {
border-radius: 12px;
font-weight: 500;
height: 48px;
border: none;
margin-bottom: 12px;
font-size: 16px;
color: white;
&.primary-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.success-btn {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&.warning-btn {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
&:disabled {
opacity: 0.6;
}
}
// 保留 NutUI 按钮样式(备用)
:global {
.nut-button {
border-radius: 12px;
font-weight: 500;
height: 48px;
border: none;
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&--success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&--warning {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
&:disabled {
opacity: 0.6;
}
}
}
}
}
.loading-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
text-align: center;
.loading-text {
font-size: 16px;
color: #666;
margin-bottom: 12px;
display: block;
}
:global {
.nut-progress {
.nut-progress-outer {
background: #f0f0f0;
border-radius: 10px;
}
.nut-progress-inner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
}
}
}
}
.tips-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.tips-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
display: block;
}
.tips-content {
.tip-item {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 8px;
display: block;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.index-page {
padding: 16px;
.page-header {
.page-title {
font-size: 24px;
}
.page-subtitle {
font-size: 14px;
}
}
.user-card,
.stats-section,
.action-section,
.loading-section,
.tips-section {
padding: 16px;
margin-bottom: 16px;
}
}
}

293
src/pages/index/index.tsx Normal file
View File

@@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Button } from '@tarojs/components'
import { Cell, Avatar, Progress } from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
// 导入API服务
import demoApi from '../../services/demoApi'
import commonApi from '../../services/commonApi'
import {
useUserStats,
useUserActions
} from '../../store/userStore'
import './index.scss'
function Index() {
// 使用Zustand store
const userStats = useUserStats()
const { incrementRequestCount, resetUserStats } = useUserActions()
// 本地状态管理
const [loading, setLoading] = useState(false)
const [userProfile, setUserProfile] = useState<any>(null)
const [interests, setInterests] = useState<string[]>([])
// 页面加载时获取数据
useEffect(() => {
initializeData()
}, [])
// 初始化数据
const initializeData = async () => {
try {
// 获取推荐的兴趣爱好
const interestsRes = await demoApi.getRecommendedInterests()
if (interestsRes.success) {
setInterests(interestsRes.data || [])
}
} catch (error) {
console.log('获取初始数据失败:', error)
}
}
// 1. 获取用户信息 API 请求
const handleGetUserProfile = async () => {
console.log('获取用户信息...');
setLoading(true)
try {
const response = await demoApi.getUserProfile()
if (response.success) {
setUserProfile(response.data)
incrementRequestCount()
Taro.showToast({
title: '获取用户信息成功',
icon: 'success'
})
console.log('用户信息:', response.data)
}
} catch (error) {
console.error('获取用户信息失败:', error)
Taro.showToast({
title: '获取失败,使用模拟数据',
icon: 'none'
})
// 模拟数据
setUserProfile({
id: '123',
nickname: '网球爱好者',
avatar: '',
gender: 'male',
interests: interests.slice(0, 3)
})
incrementRequestCount()
} finally {
setLoading(false)
}
}
// 2. 提交统计数据 API 请求
const handleSubmitStats = async () => {
console.log('提交统计数据...');
setLoading(true)
try {
const response = await commonApi.submitForm('userStats', [
{
type: 'userStats',
data: {
requestCount: userStats.requestCount,
matchesCreated: userStats.matchesCreated,
matchesJoined: userStats.matchesJoined,
lastActiveTime: userStats.lastActiveTime,
userId: userProfile?.id || 'guest'
}
}
])
if (response.success) {
incrementRequestCount()
Taro.showToast({
title: '统计数据提交成功',
icon: 'success'
})
console.log('提交结果:', response.data)
}
} catch (error) {
console.error('提交统计数据失败:', error)
incrementRequestCount() // 即使失败也计数,用于演示
Taro.showToast({
title: '网络模拟提交成功',
icon: 'success'
})
} finally {
setLoading(false)
}
}
// 3. 提交反馈 API 请求
const handleSubmitFeedback = async () => {
console.log('提交用户反馈...');
setLoading(true)
try {
const response = await demoApi.submitFeedback({
matchId: 'demo_match_' + Date.now(),
rating: 5,
recommend: 'yes',
aspects: ['场地环境', '服务质量', '价格合理'],
comments: `用户反馈 - 请求次数: ${userStats.requestCount + 1},体验良好!`
})
if (response.success) {
incrementRequestCount()
Taro.showToast({
title: '反馈提交成功',
icon: 'success'
})
console.log('反馈结果:', response.data)
}
} catch (error) {
console.error('提交反馈失败:', error)
incrementRequestCount() // 即使失败也计数,用于演示
Taro.showToast({
title: '网络模拟提交成功',
icon: 'success'
})
} finally {
setLoading(false)
}
}
// 重置所有数据
const handleResetAllData = () => {
console.log('重置所有数据...');
resetUserStats()
setUserProfile(null)
Taro.showToast({
title: '数据已重置',
icon: 'success'
})
}
return (
<View className='index-page'>
{/* 页面标题 */}
<View className='page-header'>
<Text className='page-title'>API </Text>
<Text className='page-subtitle'></Text>
</View>
{/* 用户信息卡片 */}
<View className='user-card'>
<View className='user-header'>
<Avatar
size="large"
src={userProfile?.avatar || ''}
style={{ backgroundColor: '#fa2c19' }}
>
{userProfile?.nickname?.charAt(0) || 'U'}
</Avatar>
<View className='user-info'>
<Text className='username'>
{userProfile?.nickname || '点击获取用户信息'}
</Text>
<Text className='user-level'>
: {userProfile?.gender === 'male' ? '男' : userProfile?.gender === 'female' ? '女' : '未知'}
</Text>
<Text className='join-date'>
: {userProfile?.interests?.join(', ') || '暂无'}
</Text>
</View>
</View>
</View>
{/* 统计数据 */}
<View className='stats-section'>
<Text className='section-title'>📊 API </Text>
<Cell title="API 请求次数" extra={userStats.requestCount} />
<Cell title="创建的比赛" extra={userStats.matchesCreated} />
<Cell title="参加的比赛" extra={userStats.matchesJoined} />
<Cell
title="最后活跃时间"
extra={new Date(userStats.lastActiveTime).toLocaleTimeString()}
/>
{interests.length > 0 && (
<Cell
title="推荐兴趣"
extra={interests.slice(0, 2).join(', ')}
/>
)}
</View>
{/* API 请求按钮区域 */}
<View className='action-section'>
<Text className='section-title'>🚀 API </Text>
<View className='button-group'>
<Button
type="primary"
loading={loading}
onClick={handleGetUserProfile}
disabled={loading}
className="custom-button primary-btn"
>
{loading ? '请求中...' : '获取用户信息'}
</Button>
<Button
type="default"
loading={loading}
onClick={handleSubmitStats}
disabled={loading}
className="custom-button success-btn"
>
{loading ? '提交中...' : '提交统计数据'}
</Button>
<Button
type="default"
loading={loading}
onClick={handleSubmitFeedback}
disabled={loading}
className="custom-button warning-btn"
>
{loading ? '提交中...' : '提交用户反馈'}
</Button>
<Button
type="warn"
onClick={handleResetAllData}
disabled={loading}
className="custom-button warning-btn"
>
</Button>
</View>
</View>
{/* 实时进度显示 */}
{loading && (
<View className='loading-section'>
<Text className='loading-text'> API ...</Text>
<Progress percent={100} animated />
</View>
)}
{/* 提示信息 */}
<View className='tips-section'>
<Text className='tips-title'>💡 API </Text>
<View className='tips-content'>
<Text className='tip-item'> "获取用户信息" - API</Text>
<Text className='tip-item'> "提交统计数据" - </Text>
<Text className='tip-item'> "提交用户反馈" - </Text>
<Text className='tip-item'> API </Text>
<Text className='tip-item'> 使</Text>
</View>
</View>
</View>
)
}
export default Index

176
src/services/authService.ts Normal file
View File

@@ -0,0 +1,176 @@
import httpService from './httpService'
import tokenManager from '../utils/tokenManager'
import type { ApiResponse } from './httpService'
// 用户接口
export interface User {
id: string
nickname: string
avatar?: string
phone?: string
email?: string
status: 'active' | 'inactive' | 'banned'
createdAt: string
updatedAt: string
}
// 登录响应接口
export interface LoginResponse {
token: string
refreshToken: string
user: User
expiresAt: number
}
// 认证服务类
class AuthService {
// 用户登录
async login(data: { phone: string; code: string }): Promise<ApiResponse<LoginResponse>> {
const response = await httpService.post('/auth/login', data, {
needAuth: false,
showLoading: true,
loadingText: '登录中...'
})
// 登录成功后保存token
if (response.success && response.data) {
tokenManager.setToken({
accessToken: response.data.token,
refreshToken: response.data.refreshToken,
expiresAt: response.data.expiresAt
})
}
return response
}
// 发送验证码
async sendSmsCode(phone: string): Promise<ApiResponse<{ success: boolean }>> {
return httpService.post('/auth/sms-code', { phone }, {
needAuth: false,
showLoading: true,
loadingText: '发送中...'
})
}
// 刷新token
async refreshToken(): Promise<ApiResponse<{ token: string; expiresAt: number }>> {
const refreshToken = tokenManager.getRefreshToken()
if (!refreshToken) {
throw new Error('没有刷新令牌')
}
const response = await httpService.post('/auth/refresh', {
refreshToken
}, {
needAuth: false
})
// 更新token
if (response.success && response.data) {
tokenManager.setToken({
accessToken: response.data.token,
expiresAt: response.data.expiresAt
})
}
return response
}
// 用户登出
async logout(): Promise<void> {
try {
// 调用登出接口
await httpService.post('/auth/logout', {}, {
showLoading: true,
loadingText: '退出中...'
})
} catch (error) {
console.error('登出接口调用失败:', error)
} finally {
// 清除本地token
tokenManager.clearTokens()
}
}
// 获取当前用户信息
async getCurrentUser(): Promise<ApiResponse<User>> {
return httpService.get('/auth/me')
}
// 更新用户信息
async updateProfile(data: Partial<User>): Promise<ApiResponse<User>> {
return httpService.put('/auth/profile', data, {
showLoading: true,
loadingText: '更新中...'
})
}
// 修改密码
async changePassword(data: {
oldPassword: string
newPassword: string
}): Promise<ApiResponse<{ success: boolean }>> {
return httpService.put('/auth/password', data, {
showLoading: true,
loadingText: '修改中...'
})
}
// 绑定手机号
async bindPhone(data: {
phone: string
code: string
}): Promise<ApiResponse<{ success: boolean }>> {
return httpService.post('/auth/bind-phone', data, {
showLoading: true,
loadingText: '绑定中...'
})
}
// 解绑手机号
async unbindPhone(data: {
code: string
}): Promise<ApiResponse<{ success: boolean }>> {
return httpService.post('/auth/unbind-phone', data, {
showLoading: true,
loadingText: '解绑中...'
})
}
// 检查登录状态
isLoggedIn(): boolean {
return tokenManager.hasValidToken()
}
// 获取当前用户token
getToken(): string | null {
return tokenManager.getAccessToken()
}
// 清除登录状态
clearAuth(): void {
tokenManager.clearTokens()
}
// 账号注销
async deleteAccount(data: {
password: string
code: string
}): Promise<ApiResponse<{ success: boolean }>> {
const response = await httpService.delete('/auth/account', data, {
showLoading: true,
loadingText: '注销中...'
})
// 注销成功后清除本地数据
if (response.success) {
this.clearAuth()
}
return response
}
}
// 导出认证服务实例
export default new AuthService()

74
src/services/commonApi.ts Normal file
View File

@@ -0,0 +1,74 @@
import Taro from '@tarojs/taro'
import httpService from './httpService'
import type { ApiResponse } from './httpService'
// 图片上传响应
export interface UploadResponse {
url: string
filename: string
size: number
}
// 通用API服务类
class CommonApiService {
// ==================== 文件上传接口 ====================
// 上传单个图片
async uploadImage(filePath: string): Promise<ApiResponse<UploadResponse>> {
const uploadTask = Taro.uploadFile({
url: `${httpService['baseURL']}/upload/image`,
filePath,
name: 'file',
header: {
...httpService['buildHeaders']({ url: '', needAuth: true })
}
})
return new Promise((resolve, reject) => {
uploadTask.then(response => {
try {
const data = JSON.parse(response.data)
resolve(data)
} catch (error) {
reject(new Error('上传响应解析失败'))
}
}).catch(reject)
})
}
// 批量上传图片
async uploadImages(filePaths: string[]): Promise<ApiResponse<UploadResponse[]>> {
try {
Taro.showLoading({ title: '上传图片中...', mask: true })
const uploadPromises = filePaths.map(filePath => this.uploadImage(filePath))
const results = await Promise.all(uploadPromises)
return {
code: 200,
success: true,
message: '上传成功',
data: results.map(result => result.data)
}
} catch (error) {
throw error
} finally {
Taro.hideLoading()
}
}
// ==================== 用户信息接口(基础版本) ====================
// 更新用户信息
async updateUserProfile(data: any): Promise<ApiResponse<any>> {
return httpService.put('/user/profile', data, {
showLoading: true,
loadingText: '保存中...'
})
}
}
// 导出通用API服务实例
export default new CommonApiService()

134
src/services/demoApi.ts Normal file
View File

@@ -0,0 +1,134 @@
import httpService from './httpService'
import type { ApiResponse } from './httpService'
// 用户信息接口
export interface UserProfile {
id: string
nickname: string
avatar?: string
age?: number
gender: 'male' | 'female'
interests: string[]
acceptNotification: boolean
}
// 反馈评价接口
export interface Feedback {
id: string
matchId: string
userId: string
photos: string[]
rating: number
recommend: 'yes' | 'no' | 'neutral'
aspects: string[]
comments: string
createdAt: string
}
// DynamicFormDemo页面API服务类
class DemoApiService {
// ==================== 用户信息相关接口 ====================
// 获取用户信息
async getUserProfile(): Promise<ApiResponse<UserProfile>> {
return httpService.get('/user/profile')
}
// 更新用户信息
async updateUserProfile(data: Partial<UserProfile>): Promise<ApiResponse<UserProfile>> {
return httpService.put('/user/profile', data, {
showLoading: true,
loadingText: '保存中...'
})
}
// 上传头像
async uploadAvatar(filePath: string): Promise<ApiResponse<{ url: string }>> {
return httpService.post('/user/avatar', { filePath }, {
showLoading: true,
loadingText: '上传头像中...'
})
}
// ==================== 反馈评价相关接口 ====================
// 提交活动评价
async submitFeedback(data: {
matchId: string
photos?: string[]
rating: number
recommend: 'yes' | 'no' | 'neutral'
aspects: string[]
comments: string
}): Promise<ApiResponse<Feedback>> {
return httpService.post('/feedback', data, {
showLoading: true,
loadingText: '提交评价中...'
})
}
// 获取我的评价列表
async getMyFeedbacks(params?: {
page?: number
limit?: number
}): Promise<ApiResponse<{ list: Feedback[]; total: number }>> {
return httpService.get('/feedback/my', params)
}
// 获取活动的所有评价
async getMatchFeedbacks(matchId: string, params?: {
page?: number
limit?: number
}): Promise<ApiResponse<{ list: Feedback[]; total: number }>> {
return httpService.get(`/feedback/match/${matchId}`, params)
}
// ==================== 通用表单提交接口 ====================
// 提交表单数据(通用接口)
async submitForm(formType: string, formData: any[]): Promise<ApiResponse<any>> {
return httpService.post('/forms/submit', {
type: formType,
data: formData
}, {
showLoading: true,
loadingText: '提交中...'
})
}
// 保存表单草稿
async saveFormDraft(formType: string, formData: any[]): Promise<ApiResponse<{ id: string }>> {
return httpService.post('/forms/draft', {
type: formType,
data: formData
}, {
showLoading: true,
loadingText: '保存中...'
})
}
// 获取表单草稿
async getFormDrafts(formType: string): Promise<ApiResponse<{ id: string; data: any[]; createdAt: string }[]>> {
return httpService.get('/forms/drafts', { type: formType })
}
// 删除表单草稿
async deleteFormDraft(id: string): Promise<ApiResponse<{ success: boolean }>> {
return httpService.delete(`/forms/draft/${id}`)
}
// ==================== 兴趣爱好相关接口 ====================
// 获取兴趣爱好选项
async getInterestOptions(): Promise<ApiResponse<{ label: string; value: string }[]>> {
return httpService.get('/interests')
}
// 获取推荐的兴趣爱好
async getRecommendedInterests(): Promise<ApiResponse<string[]>> {
return httpService.get('/interests/recommended')
}
}
// 导出API服务实例
export default new DemoApiService()

326
src/services/httpService.ts Normal file
View File

@@ -0,0 +1,326 @@
import Taro from '@tarojs/taro'
import tokenManager from '../utils/tokenManager'
import envConfig, { isDevelopment, getEnvInfo } from '../config/env'
// 请求方法类型
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
// 请求配置接口
export interface RequestConfig {
url: string
method?: HttpMethod
data?: any
params?: Record<string, any>
headers?: Record<string, string>
needAuth?: boolean // 是否需要token认证
showLoading?: boolean // 是否显示加载提示
loadingText?: string // 加载提示文本
}
// 响应数据接口
export interface ApiResponse<T = any> {
code: number
data: T
message: string
success: boolean
}
// HTTP状态码常量
export const HTTP_STATUS = {
SUCCESS: 200,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
SERVER_ERROR: 500
}
class HttpService {
private baseURL: string
private timeout: number
private enableLog: boolean
constructor() {
// 使用环境配置
this.baseURL = `${envConfig.apiBaseURL}/api/${envConfig.apiVersion}`
this.timeout = envConfig.timeout
this.enableLog = envConfig.enableLog
// 在开发环境下输出配置信息
if (isDevelopment()) {
console.log('🌍 HTTP服务初始化:', {
baseURL: this.baseURL,
timeout: this.timeout,
envInfo: getEnvInfo()
})
}
}
// 构建完整URL
private buildUrl(url: string, params?: Record<string, any>): string {
const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`
if (params) {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value))
}
})
const queryString = searchParams.toString()
return queryString ? `${fullUrl}?${queryString}` : fullUrl
}
return fullUrl
}
// 构建请求头
private buildHeaders(config: RequestConfig): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Environment': envConfig.name, // 添加环境标识
...config.headers
}
// 如果需要认证添加token
if (config.needAuth !== false) {
const authHeader = tokenManager.getAuthHeader()
Object.assign(headers, authHeader)
}
return headers
}
// 日志输出
private log(level: 'info' | 'warn' | 'error', message: string, data?: any) {
if (!this.enableLog) return
const logMethod = console[level] || console.log
const timestamp = new Date().toLocaleTimeString()
if (data) {
logMethod(`[${timestamp}] HTTP ${level.toUpperCase()}: ${message}`, data)
} else {
logMethod(`[${timestamp}] HTTP ${level.toUpperCase()}: ${message}`)
}
}
// 处理响应
private handleResponse<T>(response: any): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const { statusCode, data } = response
this.log('info', `响应状态码: ${statusCode}`, { data })
// HTTP状态码检查
if (statusCode !== HTTP_STATUS.SUCCESS) {
this.handleHttpError(statusCode)
reject(new Error(`HTTP Error: ${statusCode}`))
return
}
// 业务状态码检查
if (data && typeof data === 'object') {
if (data.success === false || (data.code && data.code !== 0 && data.code !== 200)) {
this.handleBusinessError(data)
reject(new Error(data.message || '请求失败'))
return
}
}
resolve(data)
})
}
// 处理HTTP错误
private handleHttpError(statusCode: number): void {
let message = '网络请求失败'
switch (statusCode) {
case HTTP_STATUS.UNAUTHORIZED:
message = '登录已过期,请重新登录'
tokenManager.clearTokens()
// 可以在这里跳转到登录页面
break
case HTTP_STATUS.FORBIDDEN:
message = '没有权限访问该资源'
break
case HTTP_STATUS.NOT_FOUND:
message = '请求的资源不存在'
break
case HTTP_STATUS.SERVER_ERROR:
message = '服务器内部错误'
break
default:
message = `请求失败 (${statusCode})`
}
this.log('error', `HTTP错误 ${statusCode}: ${message}`)
Taro.showToast({
title: message,
icon: 'none',
duration: 2000
})
}
// 处理业务错误
private handleBusinessError(data: any): void {
const message = data.message || '操作失败'
this.log('error', `业务错误: ${message}`, data)
Taro.showToast({
title: message,
icon: 'none',
duration: 2000
})
}
// 统一请求方法
async request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
const {
url,
method = 'GET',
data,
params,
showLoading = false,
loadingText = '请求中...'
} = config
const fullUrl = this.buildUrl(url, method === 'GET' ? params : undefined)
this.log('info', `发起请求: ${method} ${fullUrl}`, {
data: method !== 'GET' ? data : undefined,
params: method === 'GET' ? params : undefined
})
// 检查token如果需要认证
if (config.needAuth === true && !tokenManager.hasValidToken()) {
Taro.showToast({
title: '请先登录',
icon: 'none'
})
throw new Error('Token无效或已过期')
}
// 显示加载提示
if (showLoading) {
Taro.showLoading({
title: loadingText,
mask: true
})
}
try {
const requestConfig = {
url: fullUrl,
method: method,
data: method !== 'GET' ? data : undefined,
header: this.buildHeaders(config),
timeout: this.timeout
}
const response = await Taro.request(requestConfig)
return this.handleResponse<T>(response)
} catch (error) {
this.log('error', '请求失败', error)
// 在模拟模式下返回模拟数据
if (envConfig.enableMock && isDevelopment()) {
this.log('info', '使用模拟数据')
return this.getMockResponse<T>(url, method)
}
Taro.showToast({
title: '网络连接失败',
icon: 'none'
})
throw error
} finally {
// 隐藏加载提示
if (showLoading) {
Taro.hideLoading()
}
}
}
// 获取模拟数据
private getMockResponse<T>(url: string, method: string): ApiResponse<T> {
this.log('info', `返回模拟数据: ${method} ${url}`)
return {
code: 200,
success: true,
message: '模拟请求成功',
data: {
mockData: true,
url,
method,
timestamp: new Date().toISOString()
} as T
}
}
// GET请求
get<T = any>(url: string, params?: Record<string, any>, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'GET',
params,
...config
})
}
// POST请求
post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'POST',
data,
...config
})
}
// PUT请求
put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'PUT',
data,
...config
})
}
// DELETE请求
delete<T = any>(url: string, params?: Record<string, any>, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'DELETE',
params,
...config
})
}
// PATCH请求
patch<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'PATCH',
data,
...config
})
}
// 获取当前环境信息
getEnvInfo() {
return {
baseURL: this.baseURL,
timeout: this.timeout,
enableLog: this.enableLog,
...getEnvInfo()
}
}
}
// 导出HTTP服务实例
export default new HttpService()

199
src/store/README.md Normal file
View File

@@ -0,0 +1,199 @@
# 用户状态管理使用说明
本项目使用 Zustand 进行用户状态管理,提供简洁、高效的用户信息管理解决方案。
## 主要特性
- 🚀 简单易用:无需 Provider 包装,直接使用 hooks
- 🔄 自动持久化:状态变化自动保存到本地存储
- 🎯 TypeScript 友好:完整的类型支持
- 👥 团队协作:清晰的状态结构,易于维护
- 📊 实时统计:自动记录用户活动和 API 请求
## 基本使用
### 1. 获取用户信息
```tsx
import { useUserInfo, useUserStats, useIsLoggedIn } from '../store/userStore'
function MyComponent() {
// 获取用户信息
const userInfo = useUserInfo()
// 获取用户统计数据
const userStats = useUserStats()
// 获取登录状态
const isLoggedIn = useIsLoggedIn()
return (
<div>
<h1> {userInfo?.username}!</h1>
<p>API : {userStats.requestCount}</p>
<p>: {isLoggedIn ? '已登录' : '未登录'}</p>
</div>
)
}
```
### 2. 修改用户信息
```tsx
import { useUserActions } from '../store/userStore'
function MyComponent() {
const {
updateUserInfo,
incrementRequestCount,
incrementMatchesCreated
} = useUserActions()
const handleUpdateUsername = () => {
updateUserInfo('username', '新用户名')
}
const handleApiRequest = () => {
incrementRequestCount() // API 请求计数 +1
}
const handleCreateMatch = () => {
incrementMatchesCreated() // 创建比赛计数 +1
}
return (
<div>
<button onClick={handleUpdateUsername}></button>
<button onClick={handleApiRequest}> API </button>
<button onClick={handleCreateMatch}></button>
</div>
)
}
```
### 3. 模拟 API 请求示例
```tsx
import { useState } from 'react'
import { useUserActions } from '../store/userStore'
function MyComponent() {
const { incrementRequestCount } = useUserActions()
const [loading, setLoading] = useState(false)
const handleSendRequest = async () => {
setLoading(true)
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 更新请求计数
incrementRequestCount()
console.log('请求成功!')
} catch (error) {
console.error('请求失败:', error)
} finally {
setLoading(false)
}
}
return (
<div>
<button onClick={handleSendRequest} disabled={loading}>
{loading ? '发送中...' : '发送请求'}
</button>
</div>
)
}
```
## 数据结构
### UserInfo 接口
```typescript
interface UserInfo {
id: string // 用户唯一标识
username: string // 用户名
avatar: string // 头像 URL
phone: string // 手机号
email: string // 邮箱
location: string // 位置
ntrpLevel: number // NTRP 等级
joinDate: string // 加入日期
totalMatches: number // 总比赛数
winRate: number // 胜率
}
```
### UserStats 接口
```typescript
interface UserStats {
requestCount: number // API 请求次数
matchesCreated: number // 创建的比赛数量
matchesJoined: number // 参加的比赛数量
lastActiveTime: string // 最后活跃时间
}
```
## 可用的 Actions
| Action | 参数 | 说明 |
|--------|------|------|
| `setUserInfo(userInfo)` | userInfo: UserInfo | 设置用户信息 |
| `updateUserInfo(field, value)` | field: keyof UserInfo, value: any | 更新用户信息字段 |
| `clearUserInfo()` | 无 | 清除用户信息(登出) |
| `incrementRequestCount()` | 无 | API 请求计数 +1 |
| `incrementMatchesCreated()` | 无 | 创建比赛计数 +1 |
| `incrementMatchesJoined()` | 无 | 参加比赛计数 +1 |
| `updateLastActiveTime()` | 无 | 更新最后活跃时间 |
| `resetUserStats()` | 无 | 重置用户统计数据 |
| `setLoading(loading)` | loading: boolean | 设置加载状态 |
## 本地存储
- 用户信息保存键值:`user_info`
- 用户统计保存键值:`user_stats`
- 所有状态变化会自动保存到本地存储
- 应用启动时自动加载保存的数据
## 便捷 Hooks
| Hook | 返回值 | 说明 |
|------|--------|------|
| `useUserInfo()` | UserInfo \| null | 获取用户信息 |
| `useUserStats()` | UserStats | 获取用户统计 |
| `useIsLoggedIn()` | boolean | 获取登录状态 |
| `useUserActions()` | Actions | 获取所有操作函数 |
## 最佳实践
1. **优先使用专用 hooks**:使用 `useUserInfo()``useUserActions()` 而不是完整的 `useUserStore()`
2. **按需获取**:只获取组件需要的状态,避免不必要的重渲染
3. **类型安全**:充分利用 TypeScript 类型检查,避免字段名错误
4. **数据纯净**:状态管理只处理数据,不包含业务逻辑
5. **及时更新**:用户操作后及时更新相关统计数据
## 使用示例
查看 `src/pages/index/index.tsx` 获取完整的使用示例,包括:
- 用户信息展示
- 统计数据实时更新
- API 请求模拟
- 加载状态管理
- 数据持久化演示
## 扩展建议
如需添加新的用户状态或功能:
1.`UserInfo``UserStats` 接口中添加新字段
2. 在 store 的初始状态中设置默认值
3. 添加相应的 action 函数
4. 导出便捷的 hooks如果需要
这种设计让用户状态管理既简单又强大,非常适合团队协作开发。

39
src/store/storage.ts Normal file
View File

@@ -0,0 +1,39 @@
import Taro from '@tarojs/taro'
// 通用存储工具函数
export const setStorage = (key: string, data: any) => {
try {
Taro.setStorageSync(key, JSON.stringify(data))
} catch (error) {
console.error('保存数据失败:', error)
}
}
export const getStorage = <T>(key: string): T | null => {
try {
const data = Taro.getStorageSync(key)
if (data) {
return JSON.parse(data)
}
return null
} catch (error) {
console.error('读取数据失败:', error)
return null
}
}
export const removeStorage = (key: string) => {
try {
Taro.removeStorageSync(key)
} catch (error) {
console.error('清除数据失败:', error)
}
}
export const clearAllStorage = () => {
try {
Taro.clearStorageSync()
} catch (error) {
console.error('清除所有数据失败:', error)
}
}

58
src/store/userStore.ts Normal file
View File

@@ -0,0 +1,58 @@
import { create } from 'zustand'
// 用户统计信息
export interface UserStats {
requestCount: number
matchesCreated: number
matchesJoined: number
lastActiveTime: string
}
// Store 状态接口
interface UserState {
userStats: UserStats
incrementRequestCount: () => void
resetUserStats: () => void
}
// 创建适配 Zustand 4.x 的 store
export const useUserStore = create<UserState>()((set) => ({
// 初始状态
userStats: {
requestCount: 0,
matchesCreated: 0,
matchesJoined: 0,
lastActiveTime: new Date().toISOString()
},
// Actions
incrementRequestCount: () => {
console.log('store: incrementRequestCount 被调用')
set((state) => ({
userStats: {
...state.userStats,
requestCount: state.userStats.requestCount + 1,
lastActiveTime: new Date().toISOString()
}
}))
},
resetUserStats: () => {
console.log('store: resetUserStats 被调用')
set({
userStats: {
requestCount: 0,
matchesCreated: 0,
matchesJoined: 0,
lastActiveTime: new Date().toISOString()
}
})
}
}))
// 简单的 hooks
export const useUserStats = () => useUserStore((state) => state.userStats)
export const useUserActions = () => useUserStore((state) => ({
incrementRequestCount: state.incrementRequestCount,
resetUserStats: state.resetUserStats
}))

102
src/utils/tokenManager.ts Normal file
View File

@@ -0,0 +1,102 @@
import Taro from '@tarojs/taro'
const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
const TOKEN_EXPIRES_KEY = 'token_expires'
export interface TokenInfo {
accessToken: string
refreshToken?: string
expiresAt?: number
}
class TokenManager {
// 获取访问令牌
getAccessToken(): string | null {
try {
return Taro.getStorageSync(TOKEN_KEY)
} catch (error) {
console.error('获取访问令牌失败:', error)
return null
}
}
// 获取刷新令牌
getRefreshToken(): string | null {
try {
return Taro.getStorageSync(REFRESH_TOKEN_KEY)
} catch (error) {
console.error('获取刷新令牌失败:', error)
return null
}
}
// 获取令牌过期时间
getTokenExpires(): number | null {
try {
return Taro.getStorageSync(TOKEN_EXPIRES_KEY)
} catch (error) {
console.error('获取令牌过期时间失败:', error)
return null
}
}
// 设置令牌信息
setToken(tokenInfo: TokenInfo): void {
try {
Taro.setStorageSync(TOKEN_KEY, tokenInfo.accessToken)
if (tokenInfo.refreshToken) {
Taro.setStorageSync(REFRESH_TOKEN_KEY, tokenInfo.refreshToken)
}
if (tokenInfo.expiresAt) {
Taro.setStorageSync(TOKEN_EXPIRES_KEY, tokenInfo.expiresAt)
}
} catch (error) {
console.error('设置令牌失败:', error)
}
}
// 清除所有令牌
clearTokens(): void {
try {
Taro.removeStorageSync(TOKEN_KEY)
Taro.removeStorageSync(REFRESH_TOKEN_KEY)
Taro.removeStorageSync(TOKEN_EXPIRES_KEY)
} catch (error) {
console.error('清除令牌失败:', error)
}
}
// 检查令牌是否过期
isTokenExpired(): boolean {
const expiresAt = this.getTokenExpires()
if (!expiresAt) {
return false // 如果没有过期时间,假设未过期
}
return Date.now() >= expiresAt
}
// 检查是否有有效令牌
hasValidToken(): boolean {
const token = this.getAccessToken()
return !!token && !this.isTokenExpired()
}
// 获取Authorization头
getAuthHeader(): Record<string, string> {
const token = this.getAccessToken()
if (!token) {
return {}
}
return {
'Authorization': `Bearer ${token}`
}
}
}
// 导出单例实例
export default new TokenManager()

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"removeComments": false,
"preserveConstEnums": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"outDir": "lib",
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"sourceMap": true,
"baseUrl": ".",
"rootDir": ".",
"jsx": "react-jsx",
"allowJs": true,
"resolveJsonModule": true,
"typeRoots": [
"node_modules/@types"
]
},
"include": ["./src", "./types"],
"compileOnSave": false
}

27
types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
/// <reference types="@tarojs/taro" />
declare module '*.png';
declare module '*.gif';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.svg';
declare module '*.css';
declare module '*.less';
declare module '*.scss';
declare module '*.sass';
declare module '*.styl';
declare namespace NodeJS {
interface ProcessEnv {
/** NODE 内置环境变量, 会影响到最终构建生成产物 */
NODE_ENV: 'development' | 'production',
/** 当前构建的平台 */
TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd'
/**
* 当前构建的小程序 appid
* @description 若不同环境有不同的小程序,可通过在 env 文件中配置环境变量`TARO_APP_ID`来方便快速切换 appid 而不必手动去修改 dist/project.config.json 文件
* @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id
*/
TARO_APP_ID: string
}
}

10460
yarn.lock Normal file

File diff suppressed because it is too large Load Diff