Browse Source

feat: new project

master
echo 1 month ago
commit
7526a9b827
  1. 11
      .env.example
  2. 83
      .gitignore
  3. 667
      README.md
  4. 53
      app.json
  5. 66
      app/(tabs)/_layout.tsx
  6. 686
      app/(tabs)/demo.tsx
  7. 192
      app/(tabs)/index.tsx
  8. 31
      app/(tabs)/two.tsx
  9. 38
      app/+html.tsx
  10. 40
      app/+not-found.tsx
  11. 102
      app/_layout.tsx
  12. 35
      app/modal.tsx
  13. BIN
      assets/fonts/SpaceMono-Regular.ttf
  14. BIN
      assets/images/adaptive-icon.png
  15. BIN
      assets/images/favicon.png
  16. BIN
      assets/images/icon.png
  17. BIN
      assets/images/splash-icon.png
  18. 77
      components/EditScreenInfo.tsx
  19. 25
      components/ExternalLink.tsx
  20. 5
      components/StyledText.tsx
  21. 45
      components/Themed.tsx
  22. 10
      components/__tests__/StyledText-test.js
  23. 4
      components/useClientOnlyValue.ts
  24. 12
      components/useClientOnlyValue.web.ts
  25. 1
      components/useColorScheme.ts
  26. 8
      components/useColorScheme.web.ts
  27. 19
      constants/Colors.ts
  28. 283
      docs/INSTALLED_PACKAGES.md
  29. 113
      docs/KNOWN_ISSUES.md
  30. 387
      docs/LIBRARIES.md
  31. 429
      docs/USAGE_EXAMPLES.md
  32. 23
      eas.json
  33. 49
      package.json
  34. 7377
      pnpm-lock.yaml
  35. 84
      src/hooks/useDebounce.ts
  36. 114
      src/hooks/useHaptics.ts
  37. 61
      src/hooks/useThrottle.ts
  38. 29
      src/index.ts
  39. 130
      src/schemas/auth.ts
  40. 76
      src/schemas/user.ts
  41. 134
      src/services/authService.ts
  42. 90
      src/services/userService.ts
  43. 147
      src/stores/settingsStore.ts
  44. 142
      src/stores/userStore.ts
  45. 81
      src/types/index.ts
  46. 144
      src/utils/api.ts
  47. 219
      src/utils/date.ts
  48. 181
      src/utils/storage.ts
  49. 17
      tsconfig.json

11
.env.example

@ -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

@ -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

@ -0,0 +1,667 @@
# React Native Expo 热更新项目
> 基于 Expo Router 和 EAS Update 的完整 React Native 项目,支持热更新(OTA)功能
[![Expo](https://img.shields.io/badge/Expo-~54.0-000020?style=flat&logo=expo)](https://expo.dev/)
[![React Native](https://img.shields.io/badge/React%20Native-0.81-61DAFB?style=flat&logo=react)](https://reactnative.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-~5.9-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![pnpm](https://img.shields.io/badge/pnpm-latest-F69220?style=flat&logo=pnpm)](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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

Binary file not shown.

BIN
assets/images/adaptive-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/favicon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/images/icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/images/splash-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

77
components/EditScreenInfo.tsx

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

8
components/useColorScheme.web.ts

@ -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

@ -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

@ -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

@ -0,0 +1,113 @@
# 已知问题和警告说明
## ⚠ Web 平台警告
### 警告信息
```
λ WARN props.pointerEvents is deprecated. Use style.pointerEvents
```
### 原因
这个警告来自 `[email protected]` 库内部,是该库在处理某些 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` 更新到最新版本
### 相关信息
- **库版本**: [email protected]
- **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

@ -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

@ -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

@ -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

@ -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

File diff suppressed because it is too large Load Diff

84
src/hooks/useDebounce.ts

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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);
};
/**
* 32
*/
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

@ -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

@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
Loading…
Cancel
Save