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