feat: new project
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# API 配置
|
||||||
|
EXPO_PUBLIC_API_URL=https://api.example.com
|
||||||
|
|
||||||
|
# 应用信息
|
||||||
|
EXPO_PUBLIC_APP_NAME=RN Demo
|
||||||
|
EXPO_PUBLIC_APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# 其他配置
|
||||||
|
# EXPO_PUBLIC_SENTRY_DSN=
|
||||||
|
# EXPO_PUBLIC_ANALYTICS_ID=
|
||||||
|
|
||||||
83
.gitignore
vendored
Normal file
83
.gitignore
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Package manager lock files (keep pnpm-lock.yaml, ignore others)
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# EAS
|
||||||
|
.eas/
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
667
README.md
Normal file
667
README.md
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
# React Native Expo 热更新项目
|
||||||
|
|
||||||
|
> 基于 Expo Router 和 EAS Update 的完整 React Native 项目,支持热更新(OTA)功能
|
||||||
|
|
||||||
|
[](https://expo.dev/)
|
||||||
|
[](https://reactnative.dev/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://pnpm.io/)
|
||||||
|
|
||||||
|
## 📖 目录
|
||||||
|
|
||||||
|
- [项目特性](#-项目特性)
|
||||||
|
- [快速开始](#-快速开始)
|
||||||
|
- [项目结构](#-项目结构)
|
||||||
|
- [热更新使用指南](#-热更新使用指南)
|
||||||
|
- [开发指南](#-开发指南)
|
||||||
|
- [配置说明](#-配置说明)
|
||||||
|
- [常见问题](#-常见问题)
|
||||||
|
- [相关资源](#-相关资源)
|
||||||
|
|
||||||
|
## ✨ 项目特性
|
||||||
|
|
||||||
|
- 🎯 **Expo Router** - 文件路由系统,类似 Next.js,支持类型安全的导航
|
||||||
|
- 📘 **TypeScript** - 完整的类型支持,提升代码质量
|
||||||
|
- 🔥 **EAS Update** - 热更新支持(CodePush 的官方替代方案)
|
||||||
|
- ⚡ **React Native 新架构** - 启用 Fabric 渲染器和 TurboModules
|
||||||
|
- 📦 **pnpm** - 快速、节省磁盘空间的包管理器
|
||||||
|
- 🧭 **标签导航** - 开箱即用的导航示例
|
||||||
|
- 🎨 **主题支持** - 内置深色/浅色主题切换
|
||||||
|
- 📱 **跨平台** - 支持 iOS、Android 和 Web
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- **Node.js** 18+ ([下载](https://nodejs.org/))
|
||||||
|
- **pnpm** ([安装指南](https://pnpm.io/installation))
|
||||||
|
- **Expo Go** App(可选,用于快速预览)
|
||||||
|
- [iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
|
||||||
|
- [Android Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
|
||||||
|
|
||||||
|
### 第一步:安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后,你会看到一个二维码和几个选项:
|
||||||
|
|
||||||
|
- 按 **`a`** - 在 Android 模拟器/设备上运行
|
||||||
|
- 按 **`i`** - 在 iOS 模拟器上运行(仅 macOS)
|
||||||
|
- 按 **`w`** - 在浏览器中运行
|
||||||
|
- **扫描二维码** - 在 Expo Go App 中运行
|
||||||
|
|
||||||
|
### 第三步:在设备上查看
|
||||||
|
|
||||||
|
#### 方法 1:使用 Expo Go(推荐用于快速预览)
|
||||||
|
|
||||||
|
1. 在手机上安装 Expo Go
|
||||||
|
2. 扫描终端中显示的二维码
|
||||||
|
3. 应用会自动加载并运行
|
||||||
|
|
||||||
|
#### 方法 2:使用模拟器
|
||||||
|
|
||||||
|
**Android 模拟器:**
|
||||||
|
```bash
|
||||||
|
pnpm android
|
||||||
|
```
|
||||||
|
|
||||||
|
**iOS 模拟器(仅 macOS):**
|
||||||
|
```bash
|
||||||
|
pnpm ios
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web 浏览器:**
|
||||||
|
```bash
|
||||||
|
pnpm web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
rn-demo/
|
||||||
|
├── 📱 app/ # Expo Router 路由文件
|
||||||
|
│ ├── (tabs)/ # 标签导航组
|
||||||
|
│ │ ├── index.tsx # 首页 - 热更新演示 ⭐
|
||||||
|
│ │ ├── two.tsx # 第二个标签页
|
||||||
|
│ │ ├── demo.tsx # 完整示例页面
|
||||||
|
│ │ └── _layout.tsx # 标签布局
|
||||||
|
│ ├── _layout.tsx # 根布局 - 自动检查更新 ⭐
|
||||||
|
│ ├── modal.tsx # 模态页面示例
|
||||||
|
│ ├── +html.tsx # Web HTML 模板
|
||||||
|
│ └── +not-found.tsx # 404 页面
|
||||||
|
│
|
||||||
|
├── 💻 src/ # 源代码目录
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ │ ├── api.ts # Axios API 配置
|
||||||
|
│ │ ├── storage.ts # AsyncStorage 封装
|
||||||
|
│ │ └── date.ts # Day.js 日期工具
|
||||||
|
│ ├── stores/ # Zustand 状态管理
|
||||||
|
│ │ ├── userStore.ts # 用户状态
|
||||||
|
│ │ └── settingsStore.ts # 应用设置
|
||||||
|
│ ├── schemas/ # Zod 验证规则
|
||||||
|
│ │ ├── auth.ts # 认证验证
|
||||||
|
│ │ └── user.ts # 用户验证
|
||||||
|
│ ├── services/ # API 服务层
|
||||||
|
│ │ ├── authService.ts # 认证服务
|
||||||
|
│ │ └── userService.ts # 用户服务
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ │ ├── useDebounce.ts # 防抖 Hook
|
||||||
|
│ │ ├── useThrottle.ts # 节流 Hook
|
||||||
|
│ │ └── useHaptics.ts # 触觉反馈 Hook
|
||||||
|
│ ├── types/ # TypeScript 类型
|
||||||
|
│ │ └── index.ts # 全局类型定义
|
||||||
|
│ └── index.ts # 统一导出 ⭐
|
||||||
|
│
|
||||||
|
├── 🧩 components/ # 可复用组件
|
||||||
|
│ ├── Themed.tsx # 主题化组件
|
||||||
|
│ ├── ExternalLink.tsx # 外部链接组件
|
||||||
|
│ └── useColorScheme.ts # 主题 Hook
|
||||||
|
│
|
||||||
|
├── 🎯 constants/ # 常量配置
|
||||||
|
│ └── Colors.ts # 颜色主题
|
||||||
|
│
|
||||||
|
├── 🎨 assets/ # 静态资源
|
||||||
|
│ ├── images/ # 图片资源
|
||||||
|
│ └── fonts/ # 字体文件
|
||||||
|
│
|
||||||
|
├── 📚 docs/ # 项目文档
|
||||||
|
│ ├── USAGE_EXAMPLES.md # 使用示例
|
||||||
|
│ └── LIBRARIES.md # 工具库使用指南
|
||||||
|
│
|
||||||
|
├── ⚙️ 配置文件
|
||||||
|
│ ├── .env.example # 环境变量示例 🎯 NEW!
|
||||||
|
│ ├── app.json # Expo 配置 ⭐
|
||||||
|
│ ├── eas.json # EAS 构建和更新配置 ⭐
|
||||||
|
│ ├── package.json # 项目依赖
|
||||||
|
│ ├── pnpm-lock.yaml # pnpm 锁文件
|
||||||
|
│ ├── tsconfig.json # TypeScript 配置
|
||||||
|
│ └── .gitignore # Git 忽略文件
|
||||||
|
│
|
||||||
|
└── 📄 文档文件
|
||||||
|
├── README.md # 项目主文档(本文件)
|
||||||
|
└── CHANGELOG.md # 更新日志
|
||||||
|
|
||||||
|
⭐ = 热更新相关的关键文件
|
||||||
|
🎯 = 新增的文件/目录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键目录说明
|
||||||
|
|
||||||
|
#### 📱 `app/` - 路由和页面
|
||||||
|
- **`app/_layout.tsx`** - 应用启动时自动检查更新
|
||||||
|
- **`app/(tabs)/index.tsx`** - 热更新演示页面
|
||||||
|
- **`app/(tabs)/demo.tsx`** - 完整示例页面,展示所有工具的使用 🎯
|
||||||
|
|
||||||
|
#### 💻 `src/` - 源代码目录 🎯
|
||||||
|
项目的核心业务逻辑代码,包含:
|
||||||
|
- **`utils/`** - 工具函数(API、存储、日期)
|
||||||
|
- **`stores/`** - 状态管理(用户、设置)
|
||||||
|
- **`schemas/`** - 数据验证(认证、用户)
|
||||||
|
- **`services/`** - API 服务层
|
||||||
|
- **`hooks/`** - 自定义 Hooks
|
||||||
|
- **`types/`** - TypeScript 类型定义
|
||||||
|
- **`index.ts`** - 统一导出所有模块
|
||||||
|
|
||||||
|
#### 📚 `docs/` - 项目文档 🎯
|
||||||
|
完善的项目文档,包含:
|
||||||
|
- **使用指南** - 如何使用各个工具
|
||||||
|
- **代码示例** - 实际的代码示例
|
||||||
|
- **配置说明** - 项目配置详解
|
||||||
|
|
||||||
|
### 核心文件说明
|
||||||
|
|
||||||
|
#### 热更新相关 ⭐
|
||||||
|
- **`app.json`** - Expo 配置,包含热更新设置
|
||||||
|
- **`eas.json`** - EAS 构建和更新通道配置
|
||||||
|
- **`app/_layout.tsx`** - 自动检查更新逻辑
|
||||||
|
- **`app/(tabs)/index.tsx`** - 手动检查更新功能
|
||||||
|
|
||||||
|
#### 工具配置 🎯
|
||||||
|
- **`src/index.ts`** - 统一导出,从这里导入所有工具
|
||||||
|
- **`.env.example`** - 环境变量配置示例
|
||||||
|
- **`tsconfig.json`** - 配置了路径别名 `@/*`
|
||||||
|
|
||||||
|
### 已安装的工具库
|
||||||
|
|
||||||
|
项目已安装并配置好以下工具库:
|
||||||
|
|
||||||
|
| 类别 | 工具库 | 用途 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **工具类** | lodash-es | JavaScript 工具函数 |
|
||||||
|
| | dayjs | 日期处理 |
|
||||||
|
| | axios | HTTP 请求 |
|
||||||
|
| **状态管理** | zustand | 轻量级状态管理 |
|
||||||
|
| **表单处理** | react-hook-form | 表单管理 |
|
||||||
|
| | zod | 数据验证 |
|
||||||
|
| **原生功能** | @react-native-async-storage/async-storage | 本地存储 |
|
||||||
|
| | expo-image | 优化的图片组件 |
|
||||||
|
| | expo-haptics | 触觉反馈 |
|
||||||
|
|
||||||
|
### 快速开始
|
||||||
|
|
||||||
|
1. **查看完整示例** - 运行应用,点击 "完整示例" tab 🎯
|
||||||
|
2. **阅读文档** - 查看 [docs/](./docs/) 目录中的文档
|
||||||
|
3. **使用工具** - 从 `@/src` 导入所需的工具
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 示例:导入工具
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
Storage,
|
||||||
|
formatDate,
|
||||||
|
useUserStore,
|
||||||
|
authService,
|
||||||
|
useDebounce,
|
||||||
|
useHaptics,
|
||||||
|
} from '@/src';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔥 热更新使用指南
|
||||||
|
|
||||||
|
> **重要提示**:热更新功能需要在真实构建(Development Build 或 Production Build)中测试,Expo Go 不支持自定义热更新配置。
|
||||||
|
|
||||||
|
### 步骤 1:登录 EAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas login
|
||||||
|
```
|
||||||
|
|
||||||
|
如果没有账号,访问 [expo.dev](https://expo.dev) 注册一个免费账号。
|
||||||
|
|
||||||
|
### 步骤 2:初始化 EAS 项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas init
|
||||||
|
```
|
||||||
|
|
||||||
|
这会:
|
||||||
|
- 创建一个唯一的项目 ID
|
||||||
|
- 自动更新 `app.json` 中的 `extra.eas.projectId`
|
||||||
|
|
||||||
|
### 步骤 3:构建开发版本
|
||||||
|
|
||||||
|
**Android 开发构建:**
|
||||||
|
```bash
|
||||||
|
eas build --profile development --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
**iOS 开发构建(需要 macOS 和 Apple 开发者账号):**
|
||||||
|
```bash
|
||||||
|
eas build --profile development --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
构建过程需要 **10-20 分钟**。完成后:
|
||||||
|
1. 在 [expo.dev](https://expo.dev) 控制台下载构建的 APK/IPA 文件
|
||||||
|
2. 安装到你的设备上
|
||||||
|
|
||||||
|
### 步骤 4:发布热更新
|
||||||
|
|
||||||
|
1. **修改代码**(例如修改 `app/(tabs)/index.tsx` 中的文本)
|
||||||
|
|
||||||
|
2. **发布更新到指定通道:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 发布到开发通道
|
||||||
|
eas update --channel development --message "测试热更新功能"
|
||||||
|
|
||||||
|
# 发布到预览通道
|
||||||
|
eas update --channel preview --message "修复了某个 bug"
|
||||||
|
|
||||||
|
# 发布到生产通道
|
||||||
|
eas update --channel production --message "v1.0.1: 添加了新功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 5:测试更新
|
||||||
|
|
||||||
|
1. 打开已安装的应用
|
||||||
|
2. 点击首页的 **"检查更新"** 按钮
|
||||||
|
3. 应用会自动下载更新
|
||||||
|
4. 点击 **"立即重启"** 应用更新
|
||||||
|
5. 重启后即可看到更新内容 🎉
|
||||||
|
|
||||||
|
### 更新通道说明
|
||||||
|
|
||||||
|
项目配置了三个更新通道(在 `eas.json` 中定义):
|
||||||
|
|
||||||
|
| 通道 | 用途 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **development** | 开发环境 | 日常开发和测试 |
|
||||||
|
| **preview** | 预览环境 | 内部测试和 QA |
|
||||||
|
| **production** | 生产环境 | 正式发布给用户 |
|
||||||
|
|
||||||
|
不同的构建配置会订阅不同的更新通道:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建订阅 development 通道的版本
|
||||||
|
eas build --profile development --platform android
|
||||||
|
|
||||||
|
# 构建订阅 preview 通道的版本
|
||||||
|
eas build --profile preview --platform android
|
||||||
|
|
||||||
|
# 构建订阅 production 通道的版本
|
||||||
|
eas build --profile production --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推荐的发布流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 开发和本地测试
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# 2. 发布到 preview 通道进行内部测试
|
||||||
|
eas update --channel preview --message "测试新功能"
|
||||||
|
|
||||||
|
# 3. 测试通过后,发布到 production 通道
|
||||||
|
eas update --channel production --message "v1.0.1: 修复了登录问题"
|
||||||
|
|
||||||
|
# 4. 如果需要回滚
|
||||||
|
eas update --channel production --branch main --message "回滚到稳定版本"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 监控和管理更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有更新历史
|
||||||
|
eas update:list
|
||||||
|
|
||||||
|
# 查看特定通道的更新
|
||||||
|
eas update:list --channel production
|
||||||
|
|
||||||
|
# 查看更新详情
|
||||||
|
eas update:view [update-id]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💻 开发指南
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发相关
|
||||||
|
pnpm start # 启动开发服务器
|
||||||
|
pnpm android # 在 Android 上运行
|
||||||
|
pnpm ios # 在 iOS 上运行(仅 macOS)
|
||||||
|
pnpm web # 在浏览器中运行
|
||||||
|
|
||||||
|
# 热更新相关
|
||||||
|
eas login # 登录 EAS
|
||||||
|
eas init # 初始化 EAS 项目
|
||||||
|
eas build --profile development --platform android # 构建开发版本
|
||||||
|
eas update --channel development --message "更新说明" # 发布热更新
|
||||||
|
eas update:list # 查看更新历史
|
||||||
|
|
||||||
|
# 构建相关
|
||||||
|
eas build --profile preview --platform android # 构建预览版本
|
||||||
|
eas build --profile production --platform android # 构建生产版本
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改代码示例
|
||||||
|
|
||||||
|
尝试修改以下内容来测试热更新:
|
||||||
|
|
||||||
|
#### 示例 1:修改首页标题
|
||||||
|
|
||||||
|
打开 `app/(tabs)/index.tsx`,找到:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Text style={styles.title}>🚀 热更新演示</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Text style={styles.title}>🎉 热更新测试成功!</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例 2:修改按钮样式
|
||||||
|
|
||||||
|
在 `app/(tabs)/index.tsx` 的 `styles` 中修改:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#FF6B6B', // 改为红色
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
paddingVertical: 15,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 20,
|
||||||
|
minWidth: 200,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例 3:添加新功能
|
||||||
|
|
||||||
|
在首页添加一个计数器:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
// 在 JSX 中添加
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={() => setCount(count + 1)}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>点击次数: {count}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后,运行 `eas update` 发布更新,然后在应用中检查更新即可看到变化。
|
||||||
|
|
||||||
|
### 自定义更新行为
|
||||||
|
|
||||||
|
#### 自动静默更新
|
||||||
|
|
||||||
|
修改 `app/_layout.tsx` 中的更新逻辑,移除 Alert 提示:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (update.isAvailable) {
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
await Updates.reloadAsync(); // 直接重启,不提示
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 强制更新
|
||||||
|
|
||||||
|
不提供"稍后"选项:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
Alert.alert(
|
||||||
|
'发现新版本',
|
||||||
|
'请更新到最新版本',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '立即更新',
|
||||||
|
onPress: async () => {
|
||||||
|
await Updates.reloadAsync();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ cancelable: false } // 不可取消
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ 配置说明
|
||||||
|
|
||||||
|
### app.json 配置
|
||||||
|
|
||||||
|
关键配置项:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "rn-demo",
|
||||||
|
"slug": "rn-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"newArchEnabled": true, // 启用 React Native 新架构
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/your-project-id" // EAS Update 服务器
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion" // 运行时版本策略
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router", // Expo Router 插件
|
||||||
|
"expo-updates" // 热更新插件
|
||||||
|
],
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "com.rndemo.app"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.rndemo.app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置说明:**
|
||||||
|
- `updates.url` - EAS Update 服务器地址(运行 `eas init` 后自动生成)
|
||||||
|
- `runtimeVersion.policy` - 运行时版本策略,确保更新兼容性
|
||||||
|
- `appVersion` - 基于 `version` 字段(当前使用)
|
||||||
|
- `nativeVersion` - 基于原生构建号
|
||||||
|
- `plugins` - 启用的 Expo 插件
|
||||||
|
- `newArchEnabled` - 启用 React Native 新架构(Fabric + TurboModules)
|
||||||
|
|
||||||
|
### eas.json 配置
|
||||||
|
|
||||||
|
构建和更新配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true, // 开发客户端
|
||||||
|
"distribution": "internal", // 内部分发
|
||||||
|
"channel": "development" // 订阅 development 更新通道
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"channel": "preview" // 订阅 preview 更新通道
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"channel": "production" // 订阅 production 更新通道
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置说明:**
|
||||||
|
- `developmentClient` - 是否为开发客户端(包含开发工具)
|
||||||
|
- `distribution` - 分发方式(`internal` 或 `store`)
|
||||||
|
- `channel` - 更新通道,决定应用接收哪个通道的更新
|
||||||
|
|
||||||
|
## 📋 热更新最佳实践
|
||||||
|
|
||||||
|
### 何时使用热更新
|
||||||
|
|
||||||
|
✅ **适合热更新的场景:**
|
||||||
|
- ✅ 修复 JavaScript/TypeScript 代码 bug
|
||||||
|
- ✅ 更新 UI 样式和布局
|
||||||
|
- ✅ 修改业务逻辑
|
||||||
|
- ✅ 更新文本内容和翻译
|
||||||
|
- ✅ 调整配置参数
|
||||||
|
- ✅ 添加新的 JS 功能
|
||||||
|
|
||||||
|
❌ **不适合热更新的场景(需要重新构建):**
|
||||||
|
- ❌ 添加/删除原生依赖(如 `react-native-camera`)
|
||||||
|
- ❌ 修改原生代码(iOS/Android)
|
||||||
|
- ❌ 更改应用权限(如相机、位置权限)
|
||||||
|
- ❌ 修改 `app.json` 中的原生配置
|
||||||
|
- ❌ 升级 React Native 或 Expo SDK 版本
|
||||||
|
- ❌ 修改应用图标或启动屏幕
|
||||||
|
|
||||||
|
### 版本管理策略
|
||||||
|
|
||||||
|
**appVersion 策略**(当前使用):
|
||||||
|
- 基于 `app.json` 中的 `version` 字段
|
||||||
|
- ✅ 优点:简单直观,易于理解
|
||||||
|
- ⚠️ 注意:每次原生构建需要手动更新版本号
|
||||||
|
|
||||||
|
**nativeVersion 策略**:
|
||||||
|
- 基于原生构建号(iOS 的 `buildNumber`,Android 的 `versionCode`)
|
||||||
|
- ✅ 优点:自动管理,无需手动更新
|
||||||
|
- ⚠️ 注意:需要配置原生构建号
|
||||||
|
|
||||||
|
### 更新策略建议
|
||||||
|
|
||||||
|
1. **开发阶段**:频繁发布到 `development` 通道
|
||||||
|
2. **测试阶段**:发布到 `preview` 通道,进行 QA 测试
|
||||||
|
3. **生产发布**:测试通过后发布到 `production` 通道
|
||||||
|
4. **紧急修复**:直接发布到 `production` 通道,快速修复线上问题
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么在 Expo Go 中看不到热更新功能?
|
||||||
|
|
||||||
|
**A:** Expo Go 是开发工具,不支持自定义热更新。需要使用 `eas build` 构建真实应用。
|
||||||
|
|
||||||
|
### Q: 更新后没有生效?
|
||||||
|
|
||||||
|
**A:** 检查以下几点:
|
||||||
|
1. 确保应用完全关闭后重新打开(不是后台切换)
|
||||||
|
2. 检查更新通道是否匹配(development/preview/production)
|
||||||
|
3. 确保 `runtimeVersion` 匹配
|
||||||
|
4. 查看控制台是否有错误信息
|
||||||
|
5. 检查网络连接
|
||||||
|
|
||||||
|
### Q: 如何回滚到之前的版本?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
```bash
|
||||||
|
# 查看更新历史
|
||||||
|
eas update:list --channel production
|
||||||
|
|
||||||
|
# 重新发布之前的更新
|
||||||
|
eas update --channel production --branch main --message "回滚到稳定版本"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 热更新的大小限制是多少?
|
||||||
|
|
||||||
|
**A:** EAS Update 没有严格的大小限制,但建议:
|
||||||
|
- 保持更新包 < 10MB 以确保良好的用户体验
|
||||||
|
- 避免在更新中包含大量图片或资源文件
|
||||||
|
- 使用 CDN 托管大型资源
|
||||||
|
|
||||||
|
### Q: 如何强制用户更新?
|
||||||
|
|
||||||
|
**A:** 修改 `app/_layout.tsx` 中的更新逻辑:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (update.isAvailable) {
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
Alert.alert(
|
||||||
|
'发现新版本',
|
||||||
|
'请更新到最新版本',
|
||||||
|
[{ text: '立即更新', onPress: async () => await Updates.reloadAsync() }],
|
||||||
|
{ cancelable: false } // 不可取消
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 更新检查的频率是多少?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- **应用启动时**:自动检查(在 `app/_layout.tsx` 中配置)
|
||||||
|
- **手动检查**:用户点击"检查更新"按钮
|
||||||
|
- **后台检查**:可以配置定时检查(需要自己实现)
|
||||||
|
|
||||||
|
### Q: 如何在开发时测试热更新?
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
1. 构建 Development Build:`eas build --profile development --platform android`
|
||||||
|
2. 安装到设备
|
||||||
|
3. 修改代码
|
||||||
|
4. 发布更新:`eas update --channel development --message "测试"`
|
||||||
|
5. 在应用中点击"检查更新"
|
||||||
|
|
||||||
|
### Q: Web 平台的警告信息怎么处理?
|
||||||
|
|
||||||
|
**A:** 运行 `pnpm web` 时可能会看到 `pointerEvents` 的弃用警告,这是 `react-native-web` 库的内部问题,不影响功能,可以安全忽略。详见 [docs/KNOWN_ISSUES.md](./docs/KNOWN_ISSUES.md)。
|
||||||
|
|
||||||
|
## 💡 开发提示
|
||||||
|
|
||||||
|
- 开发时使用 `pnpm start` 即可,支持快速刷新(Fast Refresh)
|
||||||
|
- 只有在测试热更新功能时才需要构建真实应用
|
||||||
|
- 热更新只能更新 JavaScript/TypeScript 代码,不能更新原生代码
|
||||||
|
- 建议使用 Git 管理代码版本,方便回滚
|
||||||
|
- 每次发布更新时添加清晰的 `--message` 说明,便于追踪
|
||||||
|
|
||||||
|
## 📚 相关资源
|
||||||
|
|
||||||
|
### 官方文档
|
||||||
|
- [Expo 官方文档](https://docs.expo.dev/)
|
||||||
|
- [Expo Router 文档](https://docs.expo.dev/router/introduction/)
|
||||||
|
- [EAS Update 文档](https://docs.expo.dev/eas-update/introduction/)
|
||||||
|
- [EAS Build 文档](https://docs.expo.dev/build/introduction/)
|
||||||
|
- [React Native 文档](https://reactnative.dev/)
|
||||||
|
|
||||||
|
### 学习资源
|
||||||
|
- [Expo Router 最佳实践](https://docs.expo.dev/router/best-practices/)
|
||||||
|
- [EAS Update 最佳实践](https://docs.expo.dev/eas-update/best-practices/)
|
||||||
|
- [React Native 新架构](https://reactnative.dev/docs/the-new-architecture/landing-page)
|
||||||
|
|
||||||
|
### 社区
|
||||||
|
- [Expo Discord](https://chat.expo.dev/)
|
||||||
|
- [Expo Forums](https://forums.expo.dev/)
|
||||||
|
- [React Native Community](https://reactnative.dev/community/overview)
|
||||||
|
|
||||||
|
## 📚 项目文档
|
||||||
|
|
||||||
|
更多详细文档请查看 [docs](./docs/) 目录:
|
||||||
|
|
||||||
|
- **[使用示例](./docs/USAGE_EXAMPLES.md)** - 实际代码示例
|
||||||
|
- **[工具库使用指南](./docs/LIBRARIES.md)** - 详细的工具库使用方法和示例
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
**祝你开发愉快!** 🎉
|
||||||
|
|
||||||
|
如有问题,请查看 [常见问题](#-常见问题) 或访问 [Expo 官方文档](https://docs.expo.dev/)。
|
||||||
|
|
||||||
53
app.json
Normal file
53
app.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "rn-demo",
|
||||||
|
"slug": "rn-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "rndemo",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.rndemo.app"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.rndemo.app"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-updates"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/your-project-id"
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "your-project-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/(tabs)/_layout.tsx
Normal file
66
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||||
|
import { Link, Tabs } from 'expo-router';
|
||||||
|
import { Pressable } from 'react-native';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
|
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
|
||||||
|
|
||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
function TabBarIcon(props: {
|
||||||
|
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||||
|
// Disable the static render of the header on web
|
||||||
|
// to prevent a hydration error in React Navigation v6.
|
||||||
|
headerShown: useClientOnlyValue(false, true),
|
||||||
|
}}>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Tab One',
|
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||||
|
headerRight: () => (
|
||||||
|
<Link href="/modal" asChild>
|
||||||
|
<Pressable>
|
||||||
|
{({ pressed }) => (
|
||||||
|
<FontAwesome
|
||||||
|
name="info-circle"
|
||||||
|
size={25}
|
||||||
|
color={Colors[colorScheme ?? 'light'].text}
|
||||||
|
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="two"
|
||||||
|
options={{
|
||||||
|
title: 'Tab Two',
|
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="demo"
|
||||||
|
options={{
|
||||||
|
title: '完整示例',
|
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="rocket" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
686
app/(tabs)/demo.tsx
Normal file
686
app/(tabs)/demo.tsx
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
/**
|
||||||
|
* 完整示例页面
|
||||||
|
* 展示所有工具的使用方法
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
|
||||||
|
// 导入所有工具
|
||||||
|
import {
|
||||||
|
// 工具函数
|
||||||
|
Storage,
|
||||||
|
STORAGE_KEYS,
|
||||||
|
formatDate,
|
||||||
|
formatRelativeTime,
|
||||||
|
formatChatTime,
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
useUserStore,
|
||||||
|
useUser,
|
||||||
|
useIsLoggedIn,
|
||||||
|
useSettingsStore,
|
||||||
|
useTheme,
|
||||||
|
useLanguage,
|
||||||
|
useHapticsEnabled,
|
||||||
|
|
||||||
|
// 验证规则
|
||||||
|
loginSchema,
|
||||||
|
type LoginFormData,
|
||||||
|
|
||||||
|
// API 服务
|
||||||
|
authService,
|
||||||
|
|
||||||
|
// 自定义 Hooks
|
||||||
|
useDebounce,
|
||||||
|
useThrottle,
|
||||||
|
useHaptics,
|
||||||
|
} from '@/src';
|
||||||
|
|
||||||
|
export default function DemoScreen() {
|
||||||
|
const haptics = useHaptics();
|
||||||
|
|
||||||
|
// 状态管理示例
|
||||||
|
const user = useUser();
|
||||||
|
const isLoggedIn = useIsLoggedIn();
|
||||||
|
const login = useUserStore((state) => state.login);
|
||||||
|
const logout = useUserStore((state) => state.logout);
|
||||||
|
|
||||||
|
// 设置状态
|
||||||
|
const theme = useTheme();
|
||||||
|
const language = useLanguage();
|
||||||
|
const hapticsEnabled = useHapticsEnabled();
|
||||||
|
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||||
|
const setLanguage = useSettingsStore((state) => state.setLanguage);
|
||||||
|
const setHapticsEnabled = useSettingsStore((state) => state.setHapticsEnabled);
|
||||||
|
|
||||||
|
// 本地状态
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [counter, setCounter] = useState(0);
|
||||||
|
const [storageValue, setStorageValue] = useState('');
|
||||||
|
|
||||||
|
// 表单配置
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 防抖搜索示例
|
||||||
|
const debouncedSearch = useDebounce(async (text: string) => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('执行搜索:', text);
|
||||||
|
// 模拟 API 调用
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
setSearchResults([
|
||||||
|
`结果 1: ${text}`,
|
||||||
|
`结果 2: ${text}`,
|
||||||
|
`结果 3: ${text}`,
|
||||||
|
]);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// 监听搜索文本变化
|
||||||
|
useEffect(() => {
|
||||||
|
debouncedSearch(searchText);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
// 节流点击示例
|
||||||
|
const throttledClick = useThrottle(() => {
|
||||||
|
haptics.light();
|
||||||
|
setCounter((prev) => prev + 1);
|
||||||
|
console.log('节流点击:', counter + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 登录处理
|
||||||
|
const onLogin = async (data: LoginFormData) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
haptics.light();
|
||||||
|
|
||||||
|
// 模拟登录 API 调用
|
||||||
|
console.log('登录数据:', data);
|
||||||
|
|
||||||
|
// 实际项目中使用:
|
||||||
|
// const { user, token } = await authService.login(data);
|
||||||
|
// login(user, token);
|
||||||
|
|
||||||
|
// 模拟登录成功
|
||||||
|
const mockUser = {
|
||||||
|
id: '1',
|
||||||
|
username: data.email.split('@')[0],
|
||||||
|
email: data.email,
|
||||||
|
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||||
|
nickname: '演示用户',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
login(mockUser, 'mock-token-123456');
|
||||||
|
haptics.success();
|
||||||
|
Alert.alert('成功', '登录成功!');
|
||||||
|
} catch (error: any) {
|
||||||
|
haptics.error();
|
||||||
|
Alert.alert('失败', error.message || '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 登出处理
|
||||||
|
const handleLogout = () => {
|
||||||
|
haptics.warning();
|
||||||
|
Alert.alert(
|
||||||
|
'确认',
|
||||||
|
'确定要退出登录吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '确定',
|
||||||
|
onPress: () => {
|
||||||
|
logout();
|
||||||
|
haptics.success();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储示例
|
||||||
|
const handleSaveToStorage = async () => {
|
||||||
|
try {
|
||||||
|
haptics.light();
|
||||||
|
const testData = {
|
||||||
|
message: 'Hello from AsyncStorage!',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
counter,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Storage.setObject(STORAGE_KEYS.USER_PREFERENCES, testData);
|
||||||
|
haptics.success();
|
||||||
|
Alert.alert('成功', '数据已保存到本地存储');
|
||||||
|
} catch (error) {
|
||||||
|
haptics.error();
|
||||||
|
Alert.alert('失败', '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadFromStorage = async () => {
|
||||||
|
try {
|
||||||
|
haptics.light();
|
||||||
|
const data = await Storage.getObject<any>(STORAGE_KEYS.USER_PREFERENCES);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setStorageValue(JSON.stringify(data, null, 2));
|
||||||
|
haptics.success();
|
||||||
|
} else {
|
||||||
|
setStorageValue('暂无数据');
|
||||||
|
haptics.warning();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
haptics.error();
|
||||||
|
Alert.alert('失败', '读取失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主题切换
|
||||||
|
const handleThemeChange = () => {
|
||||||
|
haptics.selection();
|
||||||
|
const themes: Array<'light' | 'dark' | 'auto'> = ['light', 'dark', 'auto'];
|
||||||
|
const currentIndex = themes.indexOf(theme);
|
||||||
|
const nextTheme = themes[(currentIndex + 1) % themes.length];
|
||||||
|
setTheme(nextTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 语言切换
|
||||||
|
const handleLanguageChange = () => {
|
||||||
|
haptics.selection();
|
||||||
|
setLanguage(language === 'zh-CN' ? 'en-US' : 'zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* 标题 */}
|
||||||
|
<Text style={styles.title}>🎯 完整功能演示</Text>
|
||||||
|
<Text style={styles.subtitle}>展示所有工具的使用方法</Text>
|
||||||
|
|
||||||
|
{/* 用户状态显示 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>👤 用户状态 (Zustand)</Text>
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<View style={styles.userInfo}>
|
||||||
|
{user?.avatar && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: user.avatar }}
|
||||||
|
style={styles.avatar}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View style={styles.userDetails}>
|
||||||
|
<Text style={styles.userName}>{user?.nickname}</Text>
|
||||||
|
<Text style={styles.userEmail}>{user?.email}</Text>
|
||||||
|
<Text style={styles.userDate}>
|
||||||
|
注册时间: {formatRelativeTime(user?.createdAt || '')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.logoutButton]}
|
||||||
|
onPress={handleLogout}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>退出</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoText}>未登录</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 登录表单 */}
|
||||||
|
{!isLoggedIn && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>🔐 登录表单 (React Hook Form + Zod)</Text>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>邮箱</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
errors.email && styles.inputError,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<Text style={styles.errorText}>{errors.email.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>密码</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
secureTextEntry
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
errors.password && styles.inputError,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<Text style={styles.errorText}>{errors.password.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.loginButton]}
|
||||||
|
onPress={handleSubmit(onLogin)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>登录</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索示例 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>🔍 防抖搜索 (useDebounce)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={setSearchText}
|
||||||
|
placeholder="输入搜索内容..."
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<View style={styles.searchResults}>
|
||||||
|
{searchResults.map((result, index) => (
|
||||||
|
<Text key={index} style={styles.searchResult}>
|
||||||
|
{result}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 节流点击示例 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>⏱️ 节流点击 (useThrottle)</Text>
|
||||||
|
<Text style={styles.infoText}>点击次数: {counter}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.primaryButton]}
|
||||||
|
onPress={throttledClick}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>快速点击测试(1秒节流)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 本地存储示例 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>💾 本地存储 (AsyncStorage)</Text>
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.primaryButton, styles.halfButton]}
|
||||||
|
onPress={handleSaveToStorage}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>保存数据</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.secondaryButton, styles.halfButton]}
|
||||||
|
onPress={handleLoadFromStorage}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>读取数据</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{storageValue && (
|
||||||
|
<View style={styles.codeBlock}>
|
||||||
|
<Text style={styles.codeText}>{storageValue}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 日期格式化示例 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>📅 日期格式化 (Day.js)</Text>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
当前时间: {formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
相对时间: {formatRelativeTime(new Date())}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
聊天时间: {formatChatTime(Date.now())}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 设置示例 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>⚙️ 应用设置</Text>
|
||||||
|
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<Text style={styles.settingLabel}>主题: {theme}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.smallButton]}
|
||||||
|
onPress={handleThemeChange}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>切换</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<Text style={styles.settingLabel}>语言: {language}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.smallButton]}
|
||||||
|
onPress={handleLanguageChange}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>切换</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<Text style={styles.settingLabel}>触觉反馈</Text>
|
||||||
|
<Switch
|
||||||
|
value={hapticsEnabled}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
haptics.selection();
|
||||||
|
setHapticsEnabled(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 触觉反馈示例 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>📳 触觉反馈 (Expo Haptics)</Text>
|
||||||
|
<View style={styles.buttonGrid}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.hapticsButton]}
|
||||||
|
onPress={() => haptics.light()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Light</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.hapticsButton]}
|
||||||
|
onPress={() => haptics.medium()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Medium</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.hapticsButton]}
|
||||||
|
onPress={() => haptics.heavy()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Heavy</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.successButton]}
|
||||||
|
onPress={() => haptics.success()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Success</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.warningButton]}
|
||||||
|
onPress={() => haptics.warning()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Warning</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.errorButton]}
|
||||||
|
onPress={() => haptics.error()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Error</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
查看代码了解更多使用方法 📖
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
userDetails: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
userDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 6,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
inputError: {
|
||||||
|
borderColor: '#ff3b30',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#ff3b30',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
loginButton: {
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
backgroundColor: '#ff3b30',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
backgroundColor: '#5856D6',
|
||||||
|
},
|
||||||
|
smallButton: {
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
halfButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
searchResults: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
searchResult: {
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
settingLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
buttonGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
hapticsButton: {
|
||||||
|
backgroundColor: '#5856D6',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '30%',
|
||||||
|
},
|
||||||
|
successButton: {
|
||||||
|
backgroundColor: '#34C759',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '30%',
|
||||||
|
},
|
||||||
|
warningButton: {
|
||||||
|
backgroundColor: '#FF9500',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '30%',
|
||||||
|
},
|
||||||
|
errorButton: {
|
||||||
|
backgroundColor: '#FF3B30',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '30%',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
192
app/(tabs)/index.tsx
Normal file
192
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import * as Updates from 'expo-updates';
|
||||||
|
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function TabOneScreen() {
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<string>('');
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
if (__DEV__) {
|
||||||
|
Alert.alert('提示', '开发模式下无法检查更新,请使用生产构建测试热更新功能');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
setUpdateInfo('正在检查更新...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = await Updates.checkForUpdateAsync();
|
||||||
|
|
||||||
|
if (update.isAvailable) {
|
||||||
|
setUpdateInfo('发现新版本,正在下载...');
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'更新完成',
|
||||||
|
'新版本已下载完成,是否立即重启应用?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '稍后',
|
||||||
|
style: 'cancel',
|
||||||
|
onPress: () => setUpdateInfo('更新已下载,稍后重启应用即可应用'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '立即重启',
|
||||||
|
onPress: async () => {
|
||||||
|
await Updates.reloadAsync();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setUpdateInfo('当前已是最新版本');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setUpdateInfo('检查更新失败: ' + (error as Error).message);
|
||||||
|
Alert.alert('错误', '检查更新失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUpdateInfo = () => {
|
||||||
|
const {
|
||||||
|
isEmbeddedLaunch,
|
||||||
|
isEmergencyLaunch,
|
||||||
|
updateId,
|
||||||
|
channel,
|
||||||
|
runtimeVersion,
|
||||||
|
} = Updates.useUpdates();
|
||||||
|
|
||||||
|
return `
|
||||||
|
运行模式: ${__DEV__ ? '开发模式' : '生产模式'}
|
||||||
|
是否为内嵌启动: ${isEmbeddedLaunch ? '是' : '否'}
|
||||||
|
是否为紧急启动: ${isEmergencyLaunch ? '是' : '否'}
|
||||||
|
更新 ID: ${updateId || '无'}
|
||||||
|
更新通道: ${channel || '无'}
|
||||||
|
运行时版本: ${runtimeVersion || '无'}
|
||||||
|
`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>🚀 热更新演示</Text>
|
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||||
|
|
||||||
|
<View style={styles.infoContainer}>
|
||||||
|
<Text style={styles.infoTitle}>当前版本信息:</Text>
|
||||||
|
<Text style={styles.infoText}>{getUpdateInfo()}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isChecking && styles.buttonDisabled]}
|
||||||
|
onPress={checkForUpdates}
|
||||||
|
disabled={isChecking}
|
||||||
|
>
|
||||||
|
{isChecking ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>检查更新</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{updateInfo ? (
|
||||||
|
<View style={styles.updateInfoContainer}>
|
||||||
|
<Text style={styles.updateInfoText}>{updateInfo}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.instructionsContainer}>
|
||||||
|
<Text style={styles.instructionsTitle}>📝 使用说明:</Text>
|
||||||
|
<Text style={styles.instructionsText}>
|
||||||
|
1. 使用 EAS Build 构建生产版本{'\n'}
|
||||||
|
2. 修改代码后运行 eas update 发布更新{'\n'}
|
||||||
|
3. 打开应用点击"检查更新"按钮{'\n'}
|
||||||
|
4. 应用会自动下载并提示重启
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
marginVertical: 20,
|
||||||
|
height: 1,
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
infoContainer: {
|
||||||
|
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 20,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
infoTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
paddingVertical: 15,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 20,
|
||||||
|
minWidth: 200,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#999',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
updateInfoContainer: {
|
||||||
|
backgroundColor: 'rgba(52, 199, 89, 0.1)',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 20,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
updateInfoText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
instructionsContainer: {
|
||||||
|
backgroundColor: 'rgba(255, 149, 0, 0.1)',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 10,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
instructionsTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
instructionsText: {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
31
app/(tabs)/two.tsx
Normal file
31
app/(tabs)/two.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function TabTwoScreen() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Tab Two</Text>
|
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||||
|
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
marginVertical: 30,
|
||||||
|
height: 1,
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
});
|
||||||
38
app/+html.tsx
Normal file
38
app/+html.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||||
|
|
||||||
|
// This file is web-only and used to configure the root HTML for every
|
||||||
|
// web page during static rendering.
|
||||||
|
// The contents of this function only run in Node.js environments and
|
||||||
|
// do not have access to the DOM or browser APIs.
|
||||||
|
export default function Root({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||||
|
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||||
|
*/}
|
||||||
|
<ScrollViewStyleReset />
|
||||||
|
|
||||||
|
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||||
|
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsiveBackground = `
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
40
app/+not-found.tsx
Normal file
40
app/+not-found.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||||
|
|
||||||
|
<Link href="/" style={styles.link}>
|
||||||
|
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#2e78b7',
|
||||||
|
},
|
||||||
|
});
|
||||||
102
app/_layout.tsx
Normal file
102
app/_layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||||
|
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
|
import { useFonts } from 'expo-font';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
import * as Updates from 'expo-updates';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Alert, Platform } from 'react-native';
|
||||||
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Catch any errors thrown by the Layout component.
|
||||||
|
ErrorBoundary,
|
||||||
|
} from 'expo-router';
|
||||||
|
|
||||||
|
export const unstable_settings = {
|
||||||
|
// Ensure that reloading on `/modal` keeps a back button present.
|
||||||
|
initialRouteName: '(tabs)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [loaded, error] = useFonts({
|
||||||
|
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||||
|
...FontAwesome.font,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) throw error;
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [loaded]);
|
||||||
|
|
||||||
|
// 检查热更新
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkForUpdates() {
|
||||||
|
if (__DEV__) {
|
||||||
|
// 开发模式下不检查更新
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = await Updates.checkForUpdateAsync();
|
||||||
|
|
||||||
|
if (update.isAvailable) {
|
||||||
|
await Updates.fetchUpdateAsync();
|
||||||
|
|
||||||
|
// 提示用户重启应用以应用更新
|
||||||
|
Alert.alert(
|
||||||
|
'更新可用',
|
||||||
|
'发现新版本,是否立即重启应用?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '稍后',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '立即重启',
|
||||||
|
onPress: async () => {
|
||||||
|
await Updates.reloadAsync();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 处理更新检查错误
|
||||||
|
console.error('检查更新失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForUpdates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RootLayoutNav />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootLayoutNav() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||||
|
</Stack>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/modal.tsx
Normal file
35
app/modal.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { Platform, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function ModalScreen() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Modal</Text>
|
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||||
|
<EditScreenInfo path="app/modal.tsx" />
|
||||||
|
|
||||||
|
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
||||||
|
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
marginVertical: 30,
|
||||||
|
height: 1,
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
assets/fonts/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/images/adaptive-icon.png
Normal file
BIN
assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/favicon.png
Normal file
BIN
assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/icon.png
Normal file
BIN
assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/splash-icon.png
Normal file
BIN
assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
77
components/EditScreenInfo.tsx
Normal file
77
components/EditScreenInfo.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ExternalLink } from './ExternalLink';
|
||||||
|
import { MonoText } from './StyledText';
|
||||||
|
import { Text, View } from './Themed';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
|
||||||
|
export default function EditScreenInfo({ path }: { path: string }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.getStartedContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Open up the code for this screen:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
||||||
|
darkColor="rgba(255,255,255,0.05)"
|
||||||
|
lightColor="rgba(0,0,0,0.05)">
|
||||||
|
<MonoText>{path}</MonoText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Change any of the text, save the file, and your app will automatically update.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpContainer}>
|
||||||
|
<ExternalLink
|
||||||
|
style={styles.helpLink}
|
||||||
|
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
|
||||||
|
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
||||||
|
Tap here if your app doesn't automatically update after making changes
|
||||||
|
</Text>
|
||||||
|
</ExternalLink>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
getStartedContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 50,
|
||||||
|
},
|
||||||
|
homeScreenFilename: {
|
||||||
|
marginVertical: 7,
|
||||||
|
},
|
||||||
|
codeHighlightContainer: {
|
||||||
|
borderRadius: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
getStartedText: {
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
helpContainer: {
|
||||||
|
marginTop: 15,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
helpLink: {
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
helpLinkText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
25
components/ExternalLink.tsx
Normal file
25
components/ExternalLink.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import React from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export function ExternalLink(
|
||||||
|
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...props}
|
||||||
|
// @ts-expect-error: External URLs are not typed.
|
||||||
|
href={props.href}
|
||||||
|
onPress={(e) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
e.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
WebBrowser.openBrowserAsync(props.href as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/StyledText.tsx
Normal file
5
components/StyledText.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Text, TextProps } from './Themed';
|
||||||
|
|
||||||
|
export function MonoText(props: TextProps) {
|
||||||
|
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||||
|
}
|
||||||
45
components/Themed.tsx
Normal file
45
components/Themed.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about Light and Dark modes:
|
||||||
|
* https://docs.expo.io/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Text as DefaultText, View as DefaultView } from 'react-native';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from './useColorScheme';
|
||||||
|
|
||||||
|
type ThemeProps = {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextProps = ThemeProps & DefaultText['props'];
|
||||||
|
export type ViewProps = ThemeProps & DefaultView['props'];
|
||||||
|
|
||||||
|
export function useThemeColor(
|
||||||
|
props: { light?: string; dark?: string },
|
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
|
) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
|
if (colorFromProps) {
|
||||||
|
return colorFromProps;
|
||||||
|
} else {
|
||||||
|
return Colors[theme][colorName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text(props: TextProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||||
|
|
||||||
|
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function View(props: ViewProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
|
|
||||||
|
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
10
components/__tests__/StyledText-test.js
Normal file
10
components/__tests__/StyledText-test.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import renderer from 'react-test-renderer';
|
||||||
|
|
||||||
|
import { MonoText } from '../StyledText';
|
||||||
|
|
||||||
|
it(`renders correctly`, () => {
|
||||||
|
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
|
||||||
|
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
4
components/useClientOnlyValue.ts
Normal file
4
components/useClientOnlyValue.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This function is web-only as native doesn't currently support server (or build-time) rendering.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
12
components/useClientOnlyValue.web.ts
Normal file
12
components/useClientOnlyValue.web.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// `useEffect` is not invoked during server rendering, meaning
|
||||||
|
// we can use this to determine if we're on the server or not.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
|
||||||
|
const [value, setValue] = React.useState<S | C>(server);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setValue(client);
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
1
components/useColorScheme.ts
Normal file
1
components/useColorScheme.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from 'react-native';
|
||||||
8
components/useColorScheme.web.ts
Normal file
8
components/useColorScheme.web.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// NOTE: The default React Native styling doesn't support server rendering.
|
||||||
|
// Server rendered styles should not change between the first render of the HTML
|
||||||
|
// and the first render on the client. Typically, web developers will use CSS media queries
|
||||||
|
// to render different styles on the client and server, these aren't directly supported in React Native
|
||||||
|
// but can be achieved using a styling library like Nativewind.
|
||||||
|
export function useColorScheme() {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
19
constants/Colors.ts
Normal file
19
constants/Colors.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const tintColorLight = '#2f95dc';
|
||||||
|
const tintColorDark = '#fff';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
light: {
|
||||||
|
text: '#000',
|
||||||
|
background: '#fff',
|
||||||
|
tint: tintColorLight,
|
||||||
|
tabIconDefault: '#ccc',
|
||||||
|
tabIconSelected: tintColorLight,
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: '#fff',
|
||||||
|
background: '#000',
|
||||||
|
tint: tintColorDark,
|
||||||
|
tabIconDefault: '#ccc',
|
||||||
|
tabIconSelected: tintColorDark,
|
||||||
|
},
|
||||||
|
};
|
||||||
283
docs/INSTALLED_PACKAGES.md
Normal file
283
docs/INSTALLED_PACKAGES.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# 📦 已安装的工具库总结
|
||||||
|
|
||||||
|
本文档列出了项目中新安装的所有工具库及其用途。
|
||||||
|
|
||||||
|
## ✅ 安装完成的库
|
||||||
|
|
||||||
|
### 🛠️ 工具类库
|
||||||
|
|
||||||
|
#### 1. **Lodash-ES** `^4.17.21`
|
||||||
|
- **用途**: 强大的 JavaScript 工具函数库(ES modules 版本)
|
||||||
|
- **功能**: 数组、对象、字符串操作,防抖、节流等
|
||||||
|
- **优势**: 支持 tree-shaking,按需导入,减小包体积
|
||||||
|
- **文档**: https://lodash.com/docs/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import { debounce, uniq } from 'lodash-es';
|
||||||
|
debounce(fn, 300); // 防抖
|
||||||
|
uniq([1, 2, 2, 3]); // 去重
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **Day.js** `^1.11.19`
|
||||||
|
- **用途**: 轻量级日期处理库(仅 2KB)
|
||||||
|
- **功能**: 日期格式化、相对时间、日期计算
|
||||||
|
- **文档**: https://day.js.org/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
dayjs().format('YYYY-MM-DD');
|
||||||
|
dayjs().fromNow(); // '几秒前'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. **Axios** `^1.13.1`
|
||||||
|
- **用途**: HTTP 请求库
|
||||||
|
- **功能**: Promise API、请求/响应拦截器、取消请求
|
||||||
|
- **文档**: https://axios-http.com/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import axios from 'axios';
|
||||||
|
const data = await axios.get('/api/users');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. **Zustand** `^5.0.8`
|
||||||
|
- **用途**: 轻量级状态管理库
|
||||||
|
- **功能**: 简单的全局状态管理,比 Redux 简单得多
|
||||||
|
- **文档**: https://zustand-demo.pmnd.rs/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand';
|
||||||
|
const useStore = create((set) => ({
|
||||||
|
count: 0,
|
||||||
|
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. **React Hook Form** `^7.66.0`
|
||||||
|
- **用途**: 高性能表单处理库
|
||||||
|
- **功能**: 表单验证、错误处理、性能优化
|
||||||
|
- **文档**: https://react-hook-form.com/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. **Zod** `^4.1.12`
|
||||||
|
- **用途**: TypeScript 优先的数据验证库
|
||||||
|
- **功能**: 数据验证、类型推断、错误处理
|
||||||
|
- **文档**: https://zod.dev/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import { z } from 'zod';
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📱 Expo 原生模块
|
||||||
|
|
||||||
|
#### 7. **AsyncStorage** `^2.2.0`
|
||||||
|
- **包名**: @react-native-async-storage/async-storage
|
||||||
|
- **用途**: React Native 本地持久化存储
|
||||||
|
- **功能**: 键值对存储、异步 API
|
||||||
|
- **文档**: https://react-native-async-storage.github.io/async-storage/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
await AsyncStorage.setItem('key', 'value');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. **Expo Image** `^3.0.10`
|
||||||
|
- **用途**: 性能优化的图片组件
|
||||||
|
- **功能**: 占位符、缓存、渐进加载
|
||||||
|
- **文档**: https://docs.expo.dev/versions/latest/sdk/image/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
<Image source={{ uri: 'https://...' }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. **Expo Haptics** `^15.0.7`
|
||||||
|
- **用途**: 触觉反馈功能
|
||||||
|
- **功能**: 震动反馈、成功/错误/警告反馈
|
||||||
|
- **文档**: https://docs.expo.dev/versions/latest/sdk/haptics/
|
||||||
|
- **示例**:
|
||||||
|
```typescript
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔧 开发工具
|
||||||
|
|
||||||
|
#### 10. **@types/lodash-es** `^4.17.12`
|
||||||
|
- **用途**: Lodash-ES 的 TypeScript 类型定义
|
||||||
|
- **功能**: 提供完整的类型提示和类型检查
|
||||||
|
- **安装类型**: devDependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 安装统计
|
||||||
|
|
||||||
|
- **总计**: 10 个包
|
||||||
|
- **生产依赖**: 9 个
|
||||||
|
- **开发依赖**: 1 个
|
||||||
|
- **总大小**: 约 15MB(node_modules)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 使用场景
|
||||||
|
|
||||||
|
### 数据处理
|
||||||
|
- **Lodash**: 数组、对象操作
|
||||||
|
- **Day.js**: 日期时间处理
|
||||||
|
|
||||||
|
### 网络请求
|
||||||
|
- **Axios**: HTTP 请求
|
||||||
|
- **Zod**: API 响应验证
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- **Zustand**: 全局状态
|
||||||
|
- **AsyncStorage**: 本地持久化
|
||||||
|
|
||||||
|
### 表单处理
|
||||||
|
- **React Hook Form**: 表单管理
|
||||||
|
- **Zod**: 表单验证
|
||||||
|
|
||||||
|
### 用户体验
|
||||||
|
- **Expo Image**: 图片优化
|
||||||
|
- **Expo Haptics**: 触觉反馈
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 查看使用指南
|
||||||
|
详细的使用方法请查看 [LIBRARIES.md](./LIBRARIES.md)
|
||||||
|
|
||||||
|
### 2. 推荐的项目结构
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── utils/
|
||||||
|
│ ├── api.ts # Axios 配置
|
||||||
|
│ ├── storage.ts # AsyncStorage 封装
|
||||||
|
│ └── date.ts # Day.js 工具函数
|
||||||
|
├── stores/
|
||||||
|
│ ├── userStore.ts # 用户状态(Zustand)
|
||||||
|
│ └── settingsStore.ts
|
||||||
|
├── schemas/
|
||||||
|
│ ├── auth.ts # 认证相关的 Zod schema
|
||||||
|
│ └── user.ts
|
||||||
|
└── hooks/
|
||||||
|
├── useDebounce.ts # Lodash debounce 封装
|
||||||
|
└── useThrottle.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 创建基础工具文件
|
||||||
|
|
||||||
|
**utils/api.ts** - Axios 配置
|
||||||
|
```typescript
|
||||||
|
import axios from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const token = await AsyncStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
```
|
||||||
|
|
||||||
|
**utils/storage.ts** - 存储工具
|
||||||
|
```typescript
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
class Storage {
|
||||||
|
static async setObject<T>(key: string, value: T) {
|
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getObject<T>(key: string): Promise<T | null> {
|
||||||
|
const value = await AsyncStorage.getItem(key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Storage;
|
||||||
|
```
|
||||||
|
|
||||||
|
**stores/userStore.ts** - 用户状态
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
user: any | null;
|
||||||
|
setUser: (user: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 最佳实践
|
||||||
|
|
||||||
|
### 1. 性能优化
|
||||||
|
- ✅ 使用 Lodash-ES 的按需导入,减小包体积
|
||||||
|
- ✅ 使用 `debounce` 和 `throttle` 优化频繁触发的事件
|
||||||
|
- ✅ 使用 Zustand 的选择器避免不必要的重渲染
|
||||||
|
- ✅ 使用 Expo Image 的缓存策略优化图片加载
|
||||||
|
|
||||||
|
### 2. 类型安全
|
||||||
|
- ✅ 使用 Zod 定义数据结构并自动生成 TypeScript 类型
|
||||||
|
- ✅ 为 Zustand store 定义完整的类型接口
|
||||||
|
- ✅ 使用 `@types/lodash-es` 获得完整的类型提示
|
||||||
|
|
||||||
|
### 3. 代码组织
|
||||||
|
- ✅ 将 API 配置集中在 `utils/api.ts`
|
||||||
|
- ✅ 将存储操作封装在 `utils/storage.ts`
|
||||||
|
- ✅ 将状态管理放在 `stores/` 目录
|
||||||
|
- ✅ 将验证规则放在 `schemas/` 目录
|
||||||
|
|
||||||
|
### 4. 错误处理
|
||||||
|
- ✅ 在 Axios 拦截器中统一处理错误
|
||||||
|
- ✅ 使用 try-catch 包裹异步操作
|
||||||
|
- ✅ 提供用户友好的错误提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [LIBRARIES.md](./LIBRARIES.md) - 详细的使用指南和示例
|
||||||
|
- [README.md](./README.md) - 项目总体说明
|
||||||
|
- [KNOWN_ISSUES.md](./KNOWN_ISSUES.md) - 已知问题和解决方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
所有工具库已成功安装并可以直接使用!这些库涵盖了:
|
||||||
|
|
||||||
|
- ✅ **数据处理** - Lodash-ES(支持 tree-shaking), Day.js
|
||||||
|
- ✅ **网络请求** - Axios
|
||||||
|
- ✅ **状态管理** - Zustand
|
||||||
|
- ✅ **表单处理** - React Hook Form, Zod
|
||||||
|
- ✅ **本地存储** - AsyncStorage
|
||||||
|
- ✅ **用户体验** - Expo Image, Expo Haptics
|
||||||
|
|
||||||
|
开始使用这些强大的工具来构建你的应用吧!🚀
|
||||||
|
|
||||||
113
docs/KNOWN_ISSUES.md
Normal file
113
docs/KNOWN_ISSUES.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# 已知问题和警告说明
|
||||||
|
|
||||||
|
## ⚠️ Web 平台警告
|
||||||
|
|
||||||
|
### 警告信息
|
||||||
|
```
|
||||||
|
λ WARN props.pointerEvents is deprecated. Use style.pointerEvents
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
这个警告来自 `react-native-web@0.21.2` 库内部,是该库在处理某些 React Native 组件时产生的弃用警告。具体来说:
|
||||||
|
|
||||||
|
- React Native Web 正在从使用 `props.pointerEvents` 迁移到 `style.pointerEvents`
|
||||||
|
- 这是库内部的实现细节,不是我们应用代码的问题
|
||||||
|
- 警告来自 `View` 组件在 Web 平台上的渲染过程
|
||||||
|
|
||||||
|
### 影响
|
||||||
|
- ✅ **不影响应用功能** - 应用在 Web 平台上完全正常运行
|
||||||
|
- ✅ **不影响性能** - 只是一个弃用提示
|
||||||
|
- ✅ **不影响移动端** - 只在 Web 平台出现
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
|
||||||
|
#### 方案 1:忽略警告(推荐)
|
||||||
|
这个警告是无害的,可以安全忽略。等待 `react-native-web` 库更新到新版本后会自动解决。
|
||||||
|
|
||||||
|
#### 方案 2:抑制警告
|
||||||
|
如果你想在开发时隐藏这个警告,可以在 `app.json` 中添加配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或者在代码中添加警告过滤(不推荐):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 app/_layout.tsx 顶部添加
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (...args) => {
|
||||||
|
if (args[0]?.includes?.('pointerEvents is deprecated')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalWarn(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方案 3:等待库更新
|
||||||
|
`react-native-web` 团队正在积极维护这个库,未来版本会解决这个警告。你可以:
|
||||||
|
|
||||||
|
1. 关注 [react-native-web 更新日志](https://github.com/necolas/react-native-web/releases)
|
||||||
|
2. 定期运行 `pnpm update react-native-web` 更新到最新版本
|
||||||
|
|
||||||
|
### 相关信息
|
||||||
|
|
||||||
|
- **库版本**: react-native-web@0.21.2
|
||||||
|
- **Expo 版本**: ~54.0.22
|
||||||
|
- **React Native 版本**: 0.81.5
|
||||||
|
- **问题追踪**: 这是 react-native-web 库的已知问题,正在逐步迁移中
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
你可以通过以下方式验证应用功能正常:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Web 版本
|
||||||
|
pnpm web
|
||||||
|
|
||||||
|
# 在浏览器中打开 http://localhost:8081
|
||||||
|
# 检查所有功能是否正常工作
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 其他注意事项
|
||||||
|
|
||||||
|
### 开发模式下的其他常见警告
|
||||||
|
|
||||||
|
#### 1. Metro Bundler 警告
|
||||||
|
某些依赖可能会产生 Metro 打包警告,这些通常可以安全忽略。
|
||||||
|
|
||||||
|
#### 2. React 19 新特性警告
|
||||||
|
由于使用了 React 19.1.0,某些旧的 API 可能会有弃用警告。
|
||||||
|
|
||||||
|
#### 3. Expo Router 类型警告
|
||||||
|
TypeScript 可能会对某些 Expo Router 的动态路由产生类型警告,这是正常的。
|
||||||
|
|
||||||
|
### 生产构建
|
||||||
|
在生产构建中,这些警告不会出现,因为:
|
||||||
|
- 生产构建会移除所有开发时的警告
|
||||||
|
- 代码会被优化和压缩
|
||||||
|
- 只有关键错误会被记录
|
||||||
|
|
||||||
|
### 如何报告问题
|
||||||
|
如果你遇到其他问题:
|
||||||
|
|
||||||
|
1. 检查是否是已知问题(查看本文档)
|
||||||
|
2. 查看 [Expo 文档](https://docs.expo.dev/)
|
||||||
|
3. 搜索 [Expo GitHub Issues](https://github.com/expo/expo/issues)
|
||||||
|
4. 在项目中创建 issue 或联系开发团队
|
||||||
|
|
||||||
|
## ✅ 总结
|
||||||
|
|
||||||
|
**当前的 `pointerEvents` 警告是安全的,可以忽略。** 应用在所有平台(iOS、Android、Web)上都能正常运行。这只是一个库内部的迁移提示,不影响你的开发和生产使用。
|
||||||
|
|
||||||
|
如果你想要一个完全没有警告的开发体验,可以等待 `react-native-web` 的下一个主要版本更新,或者使用上述方案 2 临时抑制警告。
|
||||||
|
|
||||||
387
docs/LIBRARIES.md
Normal file
387
docs/LIBRARIES.md
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# 📚 常用工具库使用指南
|
||||||
|
|
||||||
|
本文档介绍项目中已安装的常用工具库及其使用方法。
|
||||||
|
|
||||||
|
## 📦 已安装的库列表
|
||||||
|
|
||||||
|
### 工具类库
|
||||||
|
| 库名 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| **lodash-es** | ^4.17.21 | JavaScript 工具函数库(ES modules 版本) |
|
||||||
|
| **dayjs** | ^1.11.19 | 轻量级日期处理库 |
|
||||||
|
| **axios** | ^1.13.1 | HTTP 请求库 |
|
||||||
|
| **zustand** | ^5.0.8 | 轻量级状态管理 |
|
||||||
|
| **react-hook-form** | ^7.66.0 | 表单处理库 |
|
||||||
|
| **zod** | ^4.1.12 | TypeScript 数据验证库 |
|
||||||
|
|
||||||
|
### Expo 原生模块
|
||||||
|
| 库名 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| **@react-native-async-storage/async-storage** | ^2.2.0 | 本地存储 |
|
||||||
|
| **expo-image** | ^3.0.10 | 优化的图片组件 |
|
||||||
|
| **expo-haptics** | ^15.0.7 | 触觉反馈 |
|
||||||
|
|
||||||
|
### 开发工具
|
||||||
|
| 库名 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| **@types/lodash-es** | ^4.17.12 | Lodash-ES TypeScript 类型定义 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. Lodash-ES - JavaScript 工具库
|
||||||
|
|
||||||
|
> **注意**:使用 `lodash-es` 而不是 `lodash`,支持 ES modules 和更好的 tree-shaking。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 推荐:按需导入(tree-shaking 友好)
|
||||||
|
import { map, filter, uniq, pick, cloneDeep, debounce } from 'lodash-es';
|
||||||
|
|
||||||
|
// 数组操作
|
||||||
|
map([1, 2, 3], n => n * 2); // [2, 4, 6]
|
||||||
|
filter([1, 2, 3, 4], n => n % 2 === 0); // [2, 4]
|
||||||
|
uniq([1, 2, 2, 3]); // [1, 2, 3]
|
||||||
|
|
||||||
|
// 对象操作
|
||||||
|
pick({ a: 1, b: 2, c: 3 }, ['a', 'b']); // { a: 1, b: 2 }
|
||||||
|
cloneDeep(obj); // 深拷贝
|
||||||
|
|
||||||
|
// 防抖和节流
|
||||||
|
const handleSearch = debounce((text) => {
|
||||||
|
console.log('Searching:', text);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// 也可以全量导入(不推荐,会增加包体积)
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
_.map([1, 2, 3], n => n * 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Day.js - 日期处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
// 格式化
|
||||||
|
dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
|
||||||
|
// 相对时间
|
||||||
|
dayjs().fromNow(); // '几秒前'
|
||||||
|
dayjs().subtract(1, 'day').fromNow(); // '1天前'
|
||||||
|
|
||||||
|
// 日期操作
|
||||||
|
dayjs().add(7, 'day'); // 7天后
|
||||||
|
dayjs().startOf('month'); // 本月第一天
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Axios - HTTP 请求
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// 创建实例
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const token = await AsyncStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
const data = await api.get('/users');
|
||||||
|
await api.post('/users', { name: 'John' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Zustand - 状态管理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
user: User | null;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 在组件中使用
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
|
const setUser = useUserStore((state) => state.setUser);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. React Hook Form + Zod - 表单处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email('请输入有效的邮箱'),
|
||||||
|
password: z.string().min(6, '密码至少6个字符'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
console.log(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="邮箱"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. AsyncStorage - 本地存储
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
// 存储
|
||||||
|
await AsyncStorage.setItem('key', JSON.stringify(value));
|
||||||
|
|
||||||
|
// 读取
|
||||||
|
const value = await AsyncStorage.getItem('key');
|
||||||
|
const data = value ? JSON.parse(value) : null;
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
await AsyncStorage.removeItem('key');
|
||||||
|
|
||||||
|
// 清空
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Expo Image - 优化的图片组件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
|
||||||
|
<Image
|
||||||
|
source={{ uri: 'https://example.com/image.jpg' }}
|
||||||
|
placeholder={require('@/assets/placeholder.png')}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={1000}
|
||||||
|
style={{ width: 300, height: 300 }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Expo Haptics - 触觉反馈
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
// 轻触反馈
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
// 成功反馈
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
|
||||||
|
// 错误反馈
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 实用示例
|
||||||
|
|
||||||
|
### 创建 API 工具类
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/api.ts
|
||||||
|
import axios from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'https://api.example.com',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const token = await AsyncStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
AsyncStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建 Storage 工具类
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/storage.ts
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
class Storage {
|
||||||
|
static async setObject<T>(key: string, value: T): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getObject<T>(key: string): Promise<T | null> {
|
||||||
|
const value = await AsyncStorage.getItem(key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(key: string): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Storage;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建用户状态管理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// stores/userStore.ts
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
user: User | null;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
clearUser: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
clearUser: () => set({ user: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'user-storage',
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日期格式化工具
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/date.ts
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
export const formatDate = (date: Date | string | number) => {
|
||||||
|
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRelativeTime = (date: Date | string | number) => {
|
||||||
|
return dayjs(date).fromNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatChatTime = (timestamp: number) => {
|
||||||
|
const date = dayjs(timestamp);
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
if (date.isSame(now, 'day')) {
|
||||||
|
return date.format('HH:mm');
|
||||||
|
} else if (date.isSame(now.subtract(1, 'day'), 'day')) {
|
||||||
|
return '昨天 ' + date.format('HH:mm');
|
||||||
|
} else if (date.isSame(now, 'year')) {
|
||||||
|
return date.format('MM-DD HH:mm');
|
||||||
|
} else {
|
||||||
|
return date.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 最佳实践
|
||||||
|
|
||||||
|
1. **代码组织**
|
||||||
|
- 将 API 配置放在 `utils/api.ts`
|
||||||
|
- 将存储工具放在 `utils/storage.ts`
|
||||||
|
- 将状态管理放在 `stores/` 目录
|
||||||
|
- 将验证规则放在 `schemas/` 目录
|
||||||
|
|
||||||
|
2. **性能优化**
|
||||||
|
- 使用 `_.debounce` 和 `_.throttle` 优化频繁触发的事件
|
||||||
|
- 使用 Zustand 的选择器避免不必要的重渲染
|
||||||
|
- 使用 Expo Image 的缓存策略
|
||||||
|
|
||||||
|
3. **类型安全**
|
||||||
|
- 使用 Zod 定义数据结构并自动生成 TypeScript 类型
|
||||||
|
- 为 Zustand store 定义完整的类型
|
||||||
|
- 使用 `@types/lodash` 获得完整的类型提示
|
||||||
|
|
||||||
|
4. **错误处理**
|
||||||
|
- 在 Axios 拦截器中统一处理错误
|
||||||
|
- 使用 try-catch 包裹异步操作
|
||||||
|
- 提供用户友好的错误提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关资源
|
||||||
|
|
||||||
|
- [Lodash 文档](https://lodash.com/docs/)
|
||||||
|
- [Day.js 文档](https://day.js.org/)
|
||||||
|
- [Axios 文档](https://axios-http.com/)
|
||||||
|
- [Zustand 文档](https://zustand-demo.pmnd.rs/)
|
||||||
|
- [React Hook Form 文档](https://react-hook-form.com/)
|
||||||
|
- [Zod 文档](https://zod.dev/)
|
||||||
|
- [AsyncStorage 文档](https://react-native-async-storage.github.io/async-storage/)
|
||||||
|
- [Expo Image 文档](https://docs.expo.dev/versions/latest/sdk/image/)
|
||||||
|
- [Expo Haptics 文档](https://docs.expo.dev/versions/latest/sdk/haptics/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:所有库都已安装并配置好,可以直接在项目中使用!🎉
|
||||||
|
|
||||||
429
docs/USAGE_EXAMPLES.md
Normal file
429
docs/USAGE_EXAMPLES.md
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# 💡 使用示例
|
||||||
|
|
||||||
|
本文档提供项目中各个工具和模块的实际使用示例。
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
- [登录功能示例](#登录功能示例)
|
||||||
|
- [用户资料更新示例](#用户资料更新示例)
|
||||||
|
- [搜索功能示例](#搜索功能示例)
|
||||||
|
- [设置页面示例](#设置页面示例)
|
||||||
|
- [列表加载示例](#列表加载示例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 登录功能示例
|
||||||
|
|
||||||
|
完整的登录页面实现,包含表单验证、API 调用、状态管理。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TextInput, TouchableOpacity, Text, Alert } from 'react-native';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import {
|
||||||
|
loginSchema,
|
||||||
|
type LoginFormData,
|
||||||
|
authService,
|
||||||
|
useUserStore,
|
||||||
|
useHaptics,
|
||||||
|
} from '@/src';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const haptics = useHaptics();
|
||||||
|
const login = useUserStore((state) => state.login);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 表单配置
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提交处理
|
||||||
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 调用登录 API
|
||||||
|
const { user, token } = await authService.login(data);
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
login(user, token);
|
||||||
|
|
||||||
|
// 触觉反馈
|
||||||
|
haptics.success();
|
||||||
|
|
||||||
|
// 跳转到首页
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error: any) {
|
||||||
|
haptics.error();
|
||||||
|
Alert.alert('登录失败', error.message || '请检查邮箱和密码');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ padding: 20 }}>
|
||||||
|
{/* 邮箱输入 */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="邮箱"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: errors.email ? 'red' : '#ccc',
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<Text style={{ color: 'red', marginTop: 5 }}>
|
||||||
|
{errors.email.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 密码输入 */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={{ marginTop: 15 }}>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="密码"
|
||||||
|
secureTextEntry
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: errors.password ? 'red' : '#ccc',
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<Text style={{ color: 'red', marginTop: 5 }}>
|
||||||
|
{errors.password.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSubmit(onSubmit)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
backgroundColor: loading ? '#ccc' : '#007AFF',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 5,
|
||||||
|
marginTop: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户资料更新示例
|
||||||
|
|
||||||
|
使用表单验证和 API 服务更新用户资料。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TextInput, TouchableOpacity, Text, Alert } from 'react-native';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import {
|
||||||
|
updateProfileSchema,
|
||||||
|
type UpdateProfileFormData,
|
||||||
|
userService,
|
||||||
|
useUserStore,
|
||||||
|
useHaptics,
|
||||||
|
} from '@/src';
|
||||||
|
|
||||||
|
export default function EditProfileScreen() {
|
||||||
|
const haptics = useHaptics();
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
|
const updateUser = useUserStore((state) => state.updateUser);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UpdateProfileFormData>({
|
||||||
|
resolver: zodResolver(updateProfileSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nickname: user?.nickname || '',
|
||||||
|
phone: user?.phone || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: UpdateProfileFormData) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 调用更新 API
|
||||||
|
const updatedUser = await userService.updateProfile(data);
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
updateUser(updatedUser);
|
||||||
|
|
||||||
|
haptics.success();
|
||||||
|
Alert.alert('成功', '资料更新成功');
|
||||||
|
} catch (error: any) {
|
||||||
|
haptics.error();
|
||||||
|
Alert.alert('失败', error.message || '更新失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ padding: 20 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="nickname"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View>
|
||||||
|
<Text>昵称</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
style={{ borderWidth: 1, padding: 10, marginTop: 5 }}
|
||||||
|
/>
|
||||||
|
{errors.nickname && (
|
||||||
|
<Text style={{ color: 'red' }}>{errors.nickname.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSubmit(onSubmit)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
padding: 15,
|
||||||
|
marginTop: 20,
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', textAlign: 'center' }}>
|
||||||
|
{loading ? '保存中...' : '保存'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 搜索功能示例
|
||||||
|
|
||||||
|
使用防抖优化搜索性能。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, TextInput, FlatList, Text } from 'react-native';
|
||||||
|
import { useDebounce } from '@/src';
|
||||||
|
|
||||||
|
export default function SearchScreen() {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [results, setResults] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 防抖搜索函数
|
||||||
|
const debouncedSearch = useDebounce(async (text: string) => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// 调用搜索 API
|
||||||
|
const response = await fetch(`/api/search?q=${text}`);
|
||||||
|
const data = await response.json();
|
||||||
|
setResults(data.results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// 监听搜索文本变化
|
||||||
|
useEffect(() => {
|
||||||
|
debouncedSearch(searchText);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, padding: 20 }}>
|
||||||
|
<TextInput
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={setSearchText}
|
||||||
|
placeholder="搜索..."
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ccc',
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <Text style={{ marginTop: 10 }}>搜索中...</Text>}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={{ padding: 10, borderBottomWidth: 1 }}>
|
||||||
|
<Text>{item.title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设置页面示例
|
||||||
|
|
||||||
|
使用状态管理和触觉反馈。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Switch, TouchableOpacity } from 'react-native';
|
||||||
|
import {
|
||||||
|
useSettingsStore,
|
||||||
|
useHaptics,
|
||||||
|
type Theme,
|
||||||
|
} from '@/src';
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const haptics = useHaptics();
|
||||||
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
|
const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled);
|
||||||
|
const hapticsEnabled = useSettingsStore((state) => state.hapticsEnabled);
|
||||||
|
|
||||||
|
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||||
|
const setNotificationsEnabled = useSettingsStore((state) => state.setNotificationsEnabled);
|
||||||
|
const setHapticsEnabled = useSettingsStore((state) => state.setHapticsEnabled);
|
||||||
|
|
||||||
|
const handleThemeChange = (newTheme: Theme) => {
|
||||||
|
haptics.selection();
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (setter: (value: boolean) => void, value: boolean) => {
|
||||||
|
haptics.light();
|
||||||
|
setter(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, padding: 20 }}>
|
||||||
|
{/* 主题选择 */}
|
||||||
|
<View style={{ marginBottom: 20 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: 'bold', marginBottom: 10 }}>
|
||||||
|
主题
|
||||||
|
</Text>
|
||||||
|
{(['light', 'dark', 'auto'] as Theme[]).map((t) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t}
|
||||||
|
onPress={() => handleThemeChange(t)}
|
||||||
|
style={{
|
||||||
|
padding: 15,
|
||||||
|
backgroundColor: theme === t ? '#007AFF' : '#f0f0f0',
|
||||||
|
marginBottom: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme === t ? 'white' : 'black' }}>
|
||||||
|
{t === 'light' ? '亮色' : t === 'dark' ? '暗色' : '自动'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 通知开关 */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>通知</Text>
|
||||||
|
<Switch
|
||||||
|
value={notificationsEnabled}
|
||||||
|
onValueChange={(value) => handleToggle(setNotificationsEnabled, value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 触觉反馈开关 */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>触觉反馈</Text>
|
||||||
|
<Switch
|
||||||
|
value={hapticsEnabled}
|
||||||
|
onValueChange={(value) => handleToggle(setHapticsEnabled, value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 更多示例
|
||||||
|
|
||||||
|
查看以下文档了解更多:
|
||||||
|
|
||||||
|
- [工具库使用指南](./LIBRARIES.md) - 各个工具库的详细用法
|
||||||
|
- [项目结构说明](./PROJECT_STRUCTURE.md) - 项目结构和最佳实践
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:这些示例都是可以直接使用的代码,复制到你的项目中即可!
|
||||||
|
|
||||||
23
eas.json
Normal file
23
eas.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 13.2.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"channel": "development"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"channel": "preview"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"channel": "production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
49
package.json
Normal file
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "rn-demo",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"axios": "^1.13.1",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"expo": "~54.0.22",
|
||||||
|
"expo-constants": "~18.0.10",
|
||||||
|
"expo-font": "~14.0.9",
|
||||||
|
"expo-haptics": "^15.0.7",
|
||||||
|
"expo-image": "^3.0.10",
|
||||||
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-router": "~6.0.14",
|
||||||
|
"expo-splash-screen": "~31.0.10",
|
||||||
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"expo-updates": "^29.0.12",
|
||||||
|
"expo-web-browser": "~15.0.9",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.66.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"zod": "^4.1.12",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"react-test-renderer": "19.1.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
7377
pnpm-lock.yaml
generated
Normal file
7377
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
84
src/hooks/useDebounce.ts
Normal file
84
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* 防抖 Hook
|
||||||
|
* 使用 lodash-es 的 debounce 函数
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖值 Hook
|
||||||
|
* @param value 需要防抖的值
|
||||||
|
* @param delay 延迟时间(毫秒)
|
||||||
|
* @returns 防抖后的值
|
||||||
|
*/
|
||||||
|
export function useDebounceValue<T>(value: T, delay: number = 300): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖函数 Hook
|
||||||
|
* @param callback 需要防抖的函数
|
||||||
|
* @param delay 延迟时间(毫秒)
|
||||||
|
* @returns 防抖后的函数
|
||||||
|
*/
|
||||||
|
export function useDebounce<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
delay: number = 300
|
||||||
|
): T {
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
|
||||||
|
// 更新 callback ref
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
// 创建防抖函数
|
||||||
|
const debouncedCallback = useMemo(() => {
|
||||||
|
const func = (...args: Parameters<T>) => {
|
||||||
|
callbackRef.current(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
return debounce(func, delay);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedCallback.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedCallback]);
|
||||||
|
|
||||||
|
return debouncedCallback as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用示例:
|
||||||
|
*
|
||||||
|
* // 防抖值
|
||||||
|
* const [searchText, setSearchText] = useState('');
|
||||||
|
* const debouncedSearchText = useDebounceValue(searchText, 500);
|
||||||
|
*
|
||||||
|
* useEffect(() => {
|
||||||
|
* // 使用防抖后的值进行搜索
|
||||||
|
* search(debouncedSearchText);
|
||||||
|
* }, [debouncedSearchText]);
|
||||||
|
*
|
||||||
|
* // 防抖函数
|
||||||
|
* const handleSearch = useDebounce((text: string) => {
|
||||||
|
* console.log('Searching:', text);
|
||||||
|
* }, 500);
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
114
src/hooks/useHaptics.ts
Normal file
114
src/hooks/useHaptics.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 触觉反馈 Hook
|
||||||
|
* 封装 Expo Haptics 功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useHapticsEnabled } from '@/src/stores/settingsStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触觉反馈 Hook
|
||||||
|
* 根据用户设置决定是否触发触觉反馈
|
||||||
|
*/
|
||||||
|
export function useHaptics() {
|
||||||
|
const hapticsEnabled = useHapticsEnabled();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻触反馈
|
||||||
|
*/
|
||||||
|
const light = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中等反馈
|
||||||
|
*/
|
||||||
|
const medium = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重触反馈
|
||||||
|
*/
|
||||||
|
const heavy = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功反馈
|
||||||
|
*/
|
||||||
|
const success = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 警告反馈
|
||||||
|
*/
|
||||||
|
const warning = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误反馈
|
||||||
|
*/
|
||||||
|
const error = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择反馈
|
||||||
|
*/
|
||||||
|
const selection = useCallback(async () => {
|
||||||
|
if (hapticsEnabled) {
|
||||||
|
await Haptics.selectionAsync();
|
||||||
|
}
|
||||||
|
}, [hapticsEnabled]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
light,
|
||||||
|
medium,
|
||||||
|
heavy,
|
||||||
|
success,
|
||||||
|
warning,
|
||||||
|
error,
|
||||||
|
selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用示例:
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const haptics = useHaptics();
|
||||||
|
*
|
||||||
|
* const handlePress = () => {
|
||||||
|
* haptics.light();
|
||||||
|
* // 执行其他操作
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* const handleSuccess = () => {
|
||||||
|
* haptics.success();
|
||||||
|
* // 显示成功消息
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <TouchableOpacity onPress={handlePress}>
|
||||||
|
* <Text>Press me</Text>
|
||||||
|
* </TouchableOpacity>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
61
src/hooks/useThrottle.ts
Normal file
61
src/hooks/useThrottle.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 节流 Hook
|
||||||
|
* 使用 lodash-es 的 throttle 函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数 Hook
|
||||||
|
* @param callback 需要节流的函数
|
||||||
|
* @param delay 延迟时间(毫秒)
|
||||||
|
* @param options 节流选项
|
||||||
|
* @returns 节流后的函数
|
||||||
|
*/
|
||||||
|
export function useThrottle<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
delay: number = 300,
|
||||||
|
options?: {
|
||||||
|
leading?: boolean;
|
||||||
|
trailing?: boolean;
|
||||||
|
}
|
||||||
|
): T {
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
|
||||||
|
// 更新 callback ref
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
// 创建节流函数
|
||||||
|
const throttledCallback = useMemo(() => {
|
||||||
|
const func = (...args: Parameters<T>) => {
|
||||||
|
callbackRef.current(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
return throttle(func, delay, options);
|
||||||
|
}, [delay, options]);
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
throttledCallback.cancel();
|
||||||
|
};
|
||||||
|
}, [throttledCallback]);
|
||||||
|
|
||||||
|
return throttledCallback as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用示例:
|
||||||
|
*
|
||||||
|
* const handleScroll = useThrottle((event) => {
|
||||||
|
* console.log('Scrolling:', event);
|
||||||
|
* }, 200);
|
||||||
|
*
|
||||||
|
* <ScrollView onScroll={handleScroll}>
|
||||||
|
* ...
|
||||||
|
* </ScrollView>
|
||||||
|
*/
|
||||||
|
|
||||||
29
src/index.ts
Normal file
29
src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 统一导出所有模块
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export { default as api, request } from './utils/api';
|
||||||
|
export { default as Storage, STORAGE_KEYS } from './utils/storage';
|
||||||
|
export * from './utils/date';
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
export * from './stores/userStore';
|
||||||
|
export * from './stores/settingsStore';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
export * from './schemas/auth';
|
||||||
|
export * from './schemas/user';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export { default as authService } from './services/authService';
|
||||||
|
export { default as userService } from './services/userService';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export * from './hooks/useDebounce';
|
||||||
|
export * from './hooks/useThrottle';
|
||||||
|
export * from './hooks/useHaptics';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
130
src/schemas/auth.ts
Normal file
130
src/schemas/auth.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 认证相关的 Zod 验证 Schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录表单 Schema
|
||||||
|
*/
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, '请输入邮箱')
|
||||||
|
.email('请输入有效的邮箱地址'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, '密码至少6个字符')
|
||||||
|
.max(20, '密码最多20个字符'),
|
||||||
|
rememberMe: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册表单 Schema
|
||||||
|
*/
|
||||||
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(3, '用户名至少3个字符')
|
||||||
|
.max(20, '用户名最多20个字符')
|
||||||
|
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, '请输入邮箱')
|
||||||
|
.email('请输入有效的邮箱地址'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, '密码至少6个字符')
|
||||||
|
.max(20, '密码最多20个字符')
|
||||||
|
.regex(/[A-Z]/, '密码必须包含至少一个大写字母')
|
||||||
|
.regex(/[a-z]/, '密码必须包含至少一个小写字母')
|
||||||
|
.regex(/[0-9]/, '密码必须包含至少一个数字'),
|
||||||
|
confirmPassword: z.string().min(1, '请确认密码'),
|
||||||
|
agreeToTerms: z.boolean().refine((val) => val === true, {
|
||||||
|
message: '请同意服务条款',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: '两次输入的密码不一致',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忘记密码 Schema
|
||||||
|
*/
|
||||||
|
export const forgotPasswordSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, '请输入邮箱')
|
||||||
|
.email('请输入有效的邮箱地址'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码 Schema
|
||||||
|
*/
|
||||||
|
export const resetPasswordSchema = z
|
||||||
|
.object({
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(6, '验证码为6位')
|
||||||
|
.max(6, '验证码为6位')
|
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, '密码至少6个字符')
|
||||||
|
.max(20, '密码最多20个字符'),
|
||||||
|
confirmPassword: z.string().min(1, '请确认密码'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: '两次输入的密码不一致',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码 Schema
|
||||||
|
*/
|
||||||
|
export const changePasswordSchema = z
|
||||||
|
.object({
|
||||||
|
oldPassword: z.string().min(1, '请输入当前密码'),
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(6, '新密码至少6个字符')
|
||||||
|
.max(20, '新密码最多20个字符'),
|
||||||
|
confirmPassword: z.string().min(1, '请确认新密码'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: '两次输入的密码不一致',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
})
|
||||||
|
.refine((data) => data.oldPassword !== data.newPassword, {
|
||||||
|
message: '新密码不能与当前密码相同',
|
||||||
|
path: ['newPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号登录 Schema
|
||||||
|
*/
|
||||||
|
export const phoneLoginSchema = z.object({
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.min(11, '请输入11位手机号')
|
||||||
|
.max(11, '请输入11位手机号')
|
||||||
|
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(6, '验证码为6位')
|
||||||
|
.max(6, '验证码为6位')
|
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript 类型推断
|
||||||
|
*/
|
||||||
|
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||||
|
export type RegisterFormData = z.infer<typeof registerSchema>;
|
||||||
|
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||||
|
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
||||||
|
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
|
||||||
|
export type PhoneLoginFormData = z.infer<typeof phoneLoginSchema>;
|
||||||
|
|
||||||
76
src/schemas/user.ts
Normal file
76
src/schemas/user.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 用户相关的 Zod 验证 Schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息 Schema
|
||||||
|
*/
|
||||||
|
export const userSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
username: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
avatar: z.string().url().optional(),
|
||||||
|
nickname: z.string().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户资料 Schema
|
||||||
|
*/
|
||||||
|
export const updateProfileSchema = z.object({
|
||||||
|
nickname: z
|
||||||
|
.string()
|
||||||
|
.min(2, '昵称至少2个字符')
|
||||||
|
.max(20, '昵称最多20个字符')
|
||||||
|
.optional(),
|
||||||
|
avatar: z.string().url('请输入有效的头像URL').optional(),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
bio: z.string().max(200, '个人简介最多200个字符').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定手机号 Schema
|
||||||
|
*/
|
||||||
|
export const bindPhoneSchema = z.object({
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.min(11, '请输入11位手机号')
|
||||||
|
.max(11, '请输入11位手机号')
|
||||||
|
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(6, '验证码为6位')
|
||||||
|
.max(6, '验证码为6位')
|
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定邮箱 Schema
|
||||||
|
*/
|
||||||
|
export const bindEmailSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, '请输入邮箱')
|
||||||
|
.email('请输入有效的邮箱地址'),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(6, '验证码为6位')
|
||||||
|
.max(6, '验证码为6位')
|
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript 类型推断
|
||||||
|
*/
|
||||||
|
export type User = z.infer<typeof userSchema>;
|
||||||
|
export type UpdateProfileFormData = z.infer<typeof updateProfileSchema>;
|
||||||
|
export type BindPhoneFormData = z.infer<typeof bindPhoneSchema>;
|
||||||
|
export type BindEmailFormData = z.infer<typeof bindEmailSchema>;
|
||||||
|
|
||||||
134
src/services/authService.ts
Normal file
134
src/services/authService.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 认证服务
|
||||||
|
* 处理登录、注册等认证相关的 API 请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/src/utils/api';
|
||||||
|
import type {
|
||||||
|
LoginFormData,
|
||||||
|
RegisterFormData,
|
||||||
|
ForgotPasswordFormData,
|
||||||
|
ResetPasswordFormData,
|
||||||
|
ChangePasswordFormData,
|
||||||
|
PhoneLoginFormData,
|
||||||
|
} from '@/src/schemas/auth';
|
||||||
|
import type { User } from '@/src/schemas/user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 响应接口
|
||||||
|
*/
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应
|
||||||
|
*/
|
||||||
|
interface LoginResponse {
|
||||||
|
user: User;
|
||||||
|
token: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务类
|
||||||
|
*/
|
||||||
|
class AuthService {
|
||||||
|
/**
|
||||||
|
* 邮箱登录
|
||||||
|
*/
|
||||||
|
async login(data: LoginFormData): Promise<LoginResponse> {
|
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>(
|
||||||
|
'/auth/login',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号登录
|
||||||
|
*/
|
||||||
|
async phoneLogin(data: PhoneLoginFormData): Promise<LoginResponse> {
|
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>(
|
||||||
|
'/auth/phone-login',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册
|
||||||
|
*/
|
||||||
|
async register(data: RegisterFormData): Promise<LoginResponse> {
|
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>(
|
||||||
|
'/auth/register',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
*/
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await request.post('/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送忘记密码邮件
|
||||||
|
*/
|
||||||
|
async forgotPassword(data: ForgotPasswordFormData): Promise<void> {
|
||||||
|
await request.post('/auth/forgot-password', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码
|
||||||
|
*/
|
||||||
|
async resetPassword(data: ResetPasswordFormData): Promise<void> {
|
||||||
|
await request.post('/auth/reset-password', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
async changePassword(data: ChangePasswordFormData): Promise<void> {
|
||||||
|
await request.post('/auth/change-password', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送验证码
|
||||||
|
*/
|
||||||
|
async sendVerificationCode(phone: string): Promise<void> {
|
||||||
|
await request.post('/auth/send-code', { phone });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 token
|
||||||
|
*/
|
||||||
|
async refreshToken(refreshToken: string): Promise<{ token: string }> {
|
||||||
|
const response = await request.post<ApiResponse<{ token: string }>>(
|
||||||
|
'/auth/refresh-token',
|
||||||
|
{ refreshToken }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 token 是否有效
|
||||||
|
*/
|
||||||
|
async verifyToken(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await request.get('/auth/verify-token');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const authService = new AuthService();
|
||||||
|
export default authService;
|
||||||
|
|
||||||
90
src/services/userService.ts
Normal file
90
src/services/userService.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 用户服务
|
||||||
|
* 处理用户信息相关的 API 请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/src/utils/api';
|
||||||
|
import type { User, UpdateProfileFormData } from '@/src/schemas/user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 响应接口
|
||||||
|
*/
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户服务类
|
||||||
|
*/
|
||||||
|
class UserService {
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
async getCurrentUser(): Promise<User> {
|
||||||
|
const response = await request.get<ApiResponse<User>>('/user/me');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息(通过 ID)
|
||||||
|
*/
|
||||||
|
async getUserById(userId: string): Promise<User> {
|
||||||
|
const response = await request.get<ApiResponse<User>>(`/user/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户资料
|
||||||
|
*/
|
||||||
|
async updateProfile(data: UpdateProfileFormData): Promise<User> {
|
||||||
|
const response = await request.put<ApiResponse<User>>('/user/profile', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传头像
|
||||||
|
*/
|
||||||
|
async uploadAvatar(file: File | Blob): Promise<{ url: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<{ url: string }>>(
|
||||||
|
'/user/avatar',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定手机号
|
||||||
|
*/
|
||||||
|
async bindPhone(phone: string, code: string): Promise<void> {
|
||||||
|
await request.post('/user/bind-phone', { phone, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定邮箱
|
||||||
|
*/
|
||||||
|
async bindEmail(email: string, code: string): Promise<void> {
|
||||||
|
await request.post('/user/bind-email', { email, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销账号
|
||||||
|
*/
|
||||||
|
async deleteAccount(): Promise<void> {
|
||||||
|
await request.delete('/user/account');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const userService = new UserService();
|
||||||
|
export default userService;
|
||||||
|
|
||||||
147
src/stores/settingsStore.ts
Normal file
147
src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 应用设置状态管理
|
||||||
|
* 使用 Zustand + AsyncStorage 持久化
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题类型
|
||||||
|
*/
|
||||||
|
export type Theme = 'light' | 'dark' | 'auto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言类型
|
||||||
|
*/
|
||||||
|
export type Language = 'zh-CN' | 'en-US';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置状态接口
|
||||||
|
*/
|
||||||
|
interface SettingsState {
|
||||||
|
// 状态
|
||||||
|
theme: Theme;
|
||||||
|
language: Language;
|
||||||
|
notificationsEnabled: boolean;
|
||||||
|
soundEnabled: boolean;
|
||||||
|
hapticsEnabled: boolean;
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
setLanguage: (language: Language) => void;
|
||||||
|
setNotificationsEnabled: (enabled: boolean) => void;
|
||||||
|
setSoundEnabled: (enabled: boolean) => void;
|
||||||
|
setHapticsEnabled: (enabled: boolean) => void;
|
||||||
|
resetSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认设置
|
||||||
|
*/
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
theme: 'auto' as Theme,
|
||||||
|
language: 'zh-CN' as Language,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
soundEnabled: true,
|
||||||
|
hapticsEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置状态 Store
|
||||||
|
*/
|
||||||
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
// 初始状态
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
|
||||||
|
// 设置主题
|
||||||
|
setTheme: (theme) => {
|
||||||
|
set({ theme });
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🎨 Theme changed:', theme);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置语言
|
||||||
|
setLanguage: (language) => {
|
||||||
|
set({ language });
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🌐 Language changed:', language);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置通知开关
|
||||||
|
setNotificationsEnabled: (enabled) => {
|
||||||
|
set({ notificationsEnabled: enabled });
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🔔 Notifications:', enabled ? 'enabled' : 'disabled');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置声音开关
|
||||||
|
setSoundEnabled: (enabled) => {
|
||||||
|
set({ soundEnabled: enabled });
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🔊 Sound:', enabled ? 'enabled' : 'disabled');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置触觉反馈开关
|
||||||
|
setHapticsEnabled: (enabled) => {
|
||||||
|
set({ hapticsEnabled: enabled });
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('📳 Haptics:', enabled ? 'enabled' : 'disabled');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置所有设置
|
||||||
|
resetSettings: () => {
|
||||||
|
set(DEFAULT_SETTINGS);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🔄 Settings reset to default');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'settings-storage',
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择器 Hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 获取主题
|
||||||
|
export const useTheme = () => useSettingsStore((state) => state.theme);
|
||||||
|
|
||||||
|
// 获取语言
|
||||||
|
export const useLanguage = () => useSettingsStore((state) => state.language);
|
||||||
|
|
||||||
|
// 获取通知状态
|
||||||
|
export const useNotificationsEnabled = () =>
|
||||||
|
useSettingsStore((state) => state.notificationsEnabled);
|
||||||
|
|
||||||
|
// 获取声音状态
|
||||||
|
export const useSoundEnabled = () =>
|
||||||
|
useSettingsStore((state) => state.soundEnabled);
|
||||||
|
|
||||||
|
// 获取触觉反馈状态
|
||||||
|
export const useHapticsEnabled = () =>
|
||||||
|
useSettingsStore((state) => state.hapticsEnabled);
|
||||||
|
|
||||||
|
// 获取设置操作方法
|
||||||
|
export const useSettingsActions = () =>
|
||||||
|
useSettingsStore((state) => ({
|
||||||
|
setTheme: state.setTheme,
|
||||||
|
setLanguage: state.setLanguage,
|
||||||
|
setNotificationsEnabled: state.setNotificationsEnabled,
|
||||||
|
setSoundEnabled: state.setSoundEnabled,
|
||||||
|
setHapticsEnabled: state.setHapticsEnabled,
|
||||||
|
resetSettings: state.resetSettings,
|
||||||
|
}));
|
||||||
|
|
||||||
142
src/stores/userStore.ts
Normal file
142
src/stores/userStore.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 用户状态管理
|
||||||
|
* 使用 Zustand + AsyncStorage 持久化
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息接口
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
nickname?: string;
|
||||||
|
phone?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户状态接口
|
||||||
|
*/
|
||||||
|
interface UserState {
|
||||||
|
// 状态
|
||||||
|
user: User | null;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
token: string | null;
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
setToken: (token: string) => void;
|
||||||
|
login: (user: User, token: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (updates: Partial<User>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户状态 Store
|
||||||
|
*/
|
||||||
|
export const useUserStore = create<UserState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// 初始状态
|
||||||
|
user: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
token: null,
|
||||||
|
|
||||||
|
// 设置用户信息
|
||||||
|
setUser: (user) => {
|
||||||
|
set({ user, isLoggedIn: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置 token
|
||||||
|
setToken: (token) => {
|
||||||
|
set({ token });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
login: (user, token) => {
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isLoggedIn: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同时保存 token 到 AsyncStorage(用于 API 请求)
|
||||||
|
AsyncStorage.setItem('auth_token', token);
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('✅ User logged in:', user.username);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
logout: () => {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除 AsyncStorage 中的 token
|
||||||
|
AsyncStorage.removeItem('auth_token');
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('👋 User logged out');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
updateUser: (updates) => {
|
||||||
|
const currentUser = get().user;
|
||||||
|
if (currentUser) {
|
||||||
|
set({
|
||||||
|
user: { ...currentUser, ...updates },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('📝 User updated:', updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'user-storage', // AsyncStorage 中的键名
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
// 可以选择性地持久化某些字段
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
token: state.token,
|
||||||
|
isLoggedIn: state.isLoggedIn,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择器 Hooks(优化性能,避免不必要的重渲染)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
export const useUser = () => useUserStore((state) => state.user);
|
||||||
|
|
||||||
|
// 获取登录状态
|
||||||
|
export const useIsLoggedIn = () => useUserStore((state) => state.isLoggedIn);
|
||||||
|
|
||||||
|
// 获取 token
|
||||||
|
export const useToken = () => useUserStore((state) => state.token);
|
||||||
|
|
||||||
|
// 获取用户操作方法
|
||||||
|
export const useUserActions = () =>
|
||||||
|
useUserStore((state) => ({
|
||||||
|
setUser: state.setUser,
|
||||||
|
setToken: state.setToken,
|
||||||
|
login: state.login,
|
||||||
|
logout: state.logout,
|
||||||
|
updateUser: state.updateUser,
|
||||||
|
}));
|
||||||
|
|
||||||
81
src/types/index.ts
Normal file
81
src/types/index.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 全局类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 响应基础接口
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页参数
|
||||||
|
*/
|
||||||
|
export interface PaginationParams {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页响应
|
||||||
|
*/
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误响应
|
||||||
|
*/
|
||||||
|
export interface ErrorResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件响应
|
||||||
|
*/
|
||||||
|
export interface UploadResponse {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航参数类型
|
||||||
|
*/
|
||||||
|
export type RootStackParamList = {
|
||||||
|
Home: undefined;
|
||||||
|
Profile: { userId: string };
|
||||||
|
Settings: undefined;
|
||||||
|
Login: undefined;
|
||||||
|
Register: undefined;
|
||||||
|
// 添加更多路由...
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环境变量类型
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
EXPO_PUBLIC_API_URL: string;
|
||||||
|
EXPO_PUBLIC_APP_NAME: string;
|
||||||
|
EXPO_PUBLIC_APP_VERSION: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
144
src/utils/api.ts
Normal file
144
src/utils/api.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Axios API 配置
|
||||||
|
* 统一管理 HTTP 请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
// API 基础配置
|
||||||
|
const API_CONFIG = {
|
||||||
|
baseURL: process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const api = axios.create(API_CONFIG);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求拦截器
|
||||||
|
* 在请求发送前添加 token 等信息
|
||||||
|
*/
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
try {
|
||||||
|
// 从本地存储获取 token
|
||||||
|
const token = await AsyncStorage.getItem('auth_token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印请求信息(开发环境)
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('📤 API Request:', {
|
||||||
|
method: config.method?.toUpperCase(),
|
||||||
|
url: config.url,
|
||||||
|
data: config.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request interceptor error:', error);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Request error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应拦截器
|
||||||
|
* 统一处理响应和错误
|
||||||
|
*/
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
// 打印响应信息(开发环境)
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('📥 API Response:', {
|
||||||
|
url: response.config.url,
|
||||||
|
status: response.status,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应数据
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
// 打印错误信息
|
||||||
|
console.error('❌ API Error:', {
|
||||||
|
url: error.config?.url,
|
||||||
|
status: error.response?.status,
|
||||||
|
message: error.message,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理不同的错误状态码
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 401:
|
||||||
|
// 未授权,清除 token 并跳转到登录页
|
||||||
|
await AsyncStorage.removeItem('auth_token');
|
||||||
|
// TODO: 导航到登录页
|
||||||
|
// router.replace('/login');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
// 禁止访问
|
||||||
|
console.error('Access forbidden');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
// 资源不存在
|
||||||
|
console.error('Resource not found');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 500:
|
||||||
|
// 服务器错误
|
||||||
|
console.error('Server error');
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('Unknown error');
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
// 请求已发送但没有收到响应
|
||||||
|
console.error('No response received');
|
||||||
|
} else {
|
||||||
|
// 请求配置出错
|
||||||
|
console.error('Request configuration error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用请求方法
|
||||||
|
*/
|
||||||
|
export const request = {
|
||||||
|
get: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
api.get<T, T>(url, config),
|
||||||
|
|
||||||
|
post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
api.post<T, T>(url, data, config),
|
||||||
|
|
||||||
|
put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
api.put<T, T>(url, data, config),
|
||||||
|
|
||||||
|
delete: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
api.delete<T, T>(url, config),
|
||||||
|
|
||||||
|
patch: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
api.patch<T, T>(url, data, config),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
219
src/utils/date.ts
Normal file
219
src/utils/date.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Day.js 日期工具函数
|
||||||
|
* 统一管理日期格式化和处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import calendar from 'dayjs/plugin/calendar';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
// 扩展插件
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(calendar);
|
||||||
|
dayjs.extend(duration);
|
||||||
|
dayjs.extend(isSameOrBefore);
|
||||||
|
dayjs.extend(isSameOrAfter);
|
||||||
|
|
||||||
|
// 设置默认语言为中文
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日期格式常量
|
||||||
|
*/
|
||||||
|
export const DATE_FORMATS = {
|
||||||
|
FULL: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
DATE: 'YYYY-MM-DD',
|
||||||
|
TIME: 'HH:mm:ss',
|
||||||
|
DATE_TIME: 'YYYY-MM-DD HH:mm',
|
||||||
|
MONTH_DAY: 'MM-DD',
|
||||||
|
HOUR_MINUTE: 'HH:mm',
|
||||||
|
YEAR_MONTH: 'YYYY-MM',
|
||||||
|
CHINESE_DATE: 'YYYY年MM月DD日',
|
||||||
|
CHINESE_FULL: 'YYYY年MM月DD日 HH:mm:ss',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
* @param date 日期(Date、时间戳或字符串)
|
||||||
|
* @param format 格式(默认:YYYY-MM-DD HH:mm:ss)
|
||||||
|
*/
|
||||||
|
export const formatDate = (
|
||||||
|
date: Date | string | number,
|
||||||
|
format: string = DATE_FORMATS.FULL
|
||||||
|
): string => {
|
||||||
|
return dayjs(date).format(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化为相对时间(如:3分钟前、2小时前)
|
||||||
|
*/
|
||||||
|
export const formatRelativeTime = (date: Date | string | number): string => {
|
||||||
|
return dayjs(date).fromNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化为日历时间(如:今天、昨天、上周)
|
||||||
|
*/
|
||||||
|
export const formatCalendarTime = (date: Date | string | number): string => {
|
||||||
|
return dayjs(date).calendar(null, {
|
||||||
|
sameDay: '[今天] HH:mm',
|
||||||
|
lastDay: '[昨天] HH:mm',
|
||||||
|
lastWeek: 'MM-DD HH:mm',
|
||||||
|
sameElse: 'YYYY-MM-DD HH:mm',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化聊天时间
|
||||||
|
* 今天:显示时间
|
||||||
|
* 昨天:显示"昨天 + 时间"
|
||||||
|
* 本年:显示"月-日 时间"
|
||||||
|
* 往年:显示"年-月-日"
|
||||||
|
*/
|
||||||
|
export const formatChatTime = (timestamp: number | string | Date): string => {
|
||||||
|
const date = dayjs(timestamp);
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
if (date.isSame(now, 'day')) {
|
||||||
|
// 今天
|
||||||
|
return date.format('HH:mm');
|
||||||
|
} else if (date.isSame(now.subtract(1, 'day'), 'day')) {
|
||||||
|
// 昨天
|
||||||
|
return '昨天 ' + date.format('HH:mm');
|
||||||
|
} else if (date.isSame(now, 'year')) {
|
||||||
|
// 本年
|
||||||
|
return date.format('MM-DD HH:mm');
|
||||||
|
} else {
|
||||||
|
// 往年
|
||||||
|
return date.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取时间差(返回对象)
|
||||||
|
*/
|
||||||
|
export const getTimeDiff = (
|
||||||
|
startDate: Date | string | number,
|
||||||
|
endDate: Date | string | number = new Date()
|
||||||
|
) => {
|
||||||
|
const start = dayjs(startDate);
|
||||||
|
const end = dayjs(endDate);
|
||||||
|
const diff = end.diff(start);
|
||||||
|
|
||||||
|
const duration = dayjs.duration(diff);
|
||||||
|
|
||||||
|
return {
|
||||||
|
years: duration.years(),
|
||||||
|
months: duration.months(),
|
||||||
|
days: duration.days(),
|
||||||
|
hours: duration.hours(),
|
||||||
|
minutes: duration.minutes(),
|
||||||
|
seconds: duration.seconds(),
|
||||||
|
milliseconds: duration.milliseconds(),
|
||||||
|
totalDays: Math.floor(duration.asDays()),
|
||||||
|
totalHours: Math.floor(duration.asHours()),
|
||||||
|
totalMinutes: Math.floor(duration.asMinutes()),
|
||||||
|
totalSeconds: Math.floor(duration.asSeconds()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是今天
|
||||||
|
*/
|
||||||
|
export const isToday = (date: Date | string | number): boolean => {
|
||||||
|
return dayjs(date).isSame(dayjs(), 'day');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是昨天
|
||||||
|
*/
|
||||||
|
export const isYesterday = (date: Date | string | number): boolean => {
|
||||||
|
return dayjs(date).isSame(dayjs().subtract(1, 'day'), 'day');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是本周
|
||||||
|
*/
|
||||||
|
export const isThisWeek = (date: Date | string | number): boolean => {
|
||||||
|
return dayjs(date).isSame(dayjs(), 'week');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是本月
|
||||||
|
*/
|
||||||
|
export const isThisMonth = (date: Date | string | number): boolean => {
|
||||||
|
return dayjs(date).isSame(dayjs(), 'month');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是本年
|
||||||
|
*/
|
||||||
|
export const isThisYear = (date: Date | string | number): boolean => {
|
||||||
|
return dayjs(date).isSame(dayjs(), 'year');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日期范围的开始和结束
|
||||||
|
*/
|
||||||
|
export const getDateRange = (type: 'day' | 'week' | 'month' | 'year') => {
|
||||||
|
const now = dayjs();
|
||||||
|
return {
|
||||||
|
start: now.startOf(type).toDate(),
|
||||||
|
end: now.endOf(type).toDate(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加时间
|
||||||
|
*/
|
||||||
|
export const addTime = (
|
||||||
|
date: Date | string | number,
|
||||||
|
amount: number,
|
||||||
|
unit: dayjs.ManipulateType
|
||||||
|
): Date => {
|
||||||
|
return dayjs(date).add(amount, unit).toDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减去时间
|
||||||
|
*/
|
||||||
|
export const subtractTime = (
|
||||||
|
date: Date | string | number,
|
||||||
|
amount: number,
|
||||||
|
unit: dayjs.ManipulateType
|
||||||
|
): Date => {
|
||||||
|
return dayjs(date).subtract(amount, unit).toDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断日期是否在范围内
|
||||||
|
*/
|
||||||
|
export const isBetween = (
|
||||||
|
date: Date | string | number,
|
||||||
|
startDate: Date | string | number,
|
||||||
|
endDate: Date | string | number
|
||||||
|
): boolean => {
|
||||||
|
const target = dayjs(date);
|
||||||
|
return target.isAfter(startDate) && target.isBefore(endDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前时间戳(毫秒)
|
||||||
|
*/
|
||||||
|
export const now = (): number => {
|
||||||
|
return dayjs().valueOf();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前时间戳(秒)
|
||||||
|
*/
|
||||||
|
export const nowInSeconds = (): number => {
|
||||||
|
return Math.floor(dayjs().valueOf() / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dayjs;
|
||||||
|
|
||||||
181
src/utils/storage.ts
Normal file
181
src/utils/storage.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* AsyncStorage 封装工具
|
||||||
|
* 提供类型安全的本地存储操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储键名常量
|
||||||
|
*/
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
AUTH_TOKEN: 'auth_token',
|
||||||
|
USER_INFO: 'user_info',
|
||||||
|
SETTINGS: 'settings',
|
||||||
|
THEME: 'theme',
|
||||||
|
LANGUAGE: 'language',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage 工具类
|
||||||
|
*/
|
||||||
|
class Storage {
|
||||||
|
/**
|
||||||
|
* 存储字符串
|
||||||
|
*/
|
||||||
|
static async setString(key: string, value: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(key, value);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`💾 Storage set: ${key}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage setString error for key "${key}":`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字符串
|
||||||
|
*/
|
||||||
|
static async getString(key: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const value = await AsyncStorage.getItem(key);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`📖 Storage get: ${key}`, value ? '✓' : '✗');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage getString error for key "${key}":`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储对象(自动序列化为 JSON)
|
||||||
|
*/
|
||||||
|
static async setObject<T>(key: string, value: T): Promise<void> {
|
||||||
|
try {
|
||||||
|
const jsonValue = JSON.stringify(value);
|
||||||
|
await AsyncStorage.setItem(key, jsonValue);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`💾 Storage set object: ${key}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage setObject error for key "${key}":`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对象(自动反序列化 JSON)
|
||||||
|
*/
|
||||||
|
static async getObject<T>(key: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const jsonValue = await AsyncStorage.getItem(key);
|
||||||
|
if (jsonValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = JSON.parse(jsonValue) as T;
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`📖 Storage get object: ${key} ✓`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage getObject error for key "${key}":`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定键
|
||||||
|
*/
|
||||||
|
static async remove(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`🗑️ Storage remove: ${key}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage remove error for key "${key}":`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有存储
|
||||||
|
*/
|
||||||
|
static async clear(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🗑️ Storage cleared all');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Storage clear error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有键名
|
||||||
|
*/
|
||||||
|
static async getAllKeys(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('🔑 Storage all keys:', keys);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Storage getAllKeys error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取
|
||||||
|
*/
|
||||||
|
static async multiGet(keys: string[]): Promise<[string, string | null][]> {
|
||||||
|
try {
|
||||||
|
const values = await AsyncStorage.multiGet(keys);
|
||||||
|
return values;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Storage multiGet error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置
|
||||||
|
*/
|
||||||
|
static async multiSet(keyValuePairs: [string, string][]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.multiSet(keyValuePairs);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`💾 Storage multiSet: ${keyValuePairs.length} items`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Storage multiSet error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除
|
||||||
|
*/
|
||||||
|
static async multiRemove(keys: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.multiRemove(keys);
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`🗑️ Storage multiRemove: ${keys.length} items`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Storage multiRemove error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Storage;
|
||||||
|
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user