diff --git a/ARCHITECTURE_REFACTOR_PLAN.md b/ARCHITECTURE_REFACTOR_PLAN.md new file mode 100644 index 0000000..1daaf2f --- /dev/null +++ b/ARCHITECTURE_REFACTOR_PLAN.md @@ -0,0 +1,231 @@ +# 架构修复方案文档 + +## 1. 重构目标 + +### 1.1 核心原则 +- **单一职责**:每个模块只负责一个明确的功能 +- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象 +- **开闭原则**:对扩展开放,对修改关闭 + +### 1.2 目标架构 +``` +src/ +├── core/ # 核心业务逻辑(框架无关) +│ ├── entities/ # 业务实体 +│ ├── repositories/ # 仓库接口 +│ └── usecases/ # 业务用例 +├── data/ # 数据层 +│ ├── datasources/ # 数据源实现(API、DB、Cache) +│ ├── repositories/ # 仓库实现 +│ └── mappers/ # 数据映射 +├── presentation/ # 表现层 +│ ├── components/ # UI组件 +│ ├── screens/ # 屏幕组件 +│ ├── stores/ # 状态管理 +│ └── hooks/ # 自定义hooks +└── infrastructure/ # 基础设施 + ├── navigation/ # 导航 + ├── theme/ # 主题 + └── services/ # 外部服务 +``` + +## 2. 具体修复方案 + +### 2.1 messageManager.ts 重构方案 + +#### 现状问题 +- 1500+行,上帝对象 +- 混合了 WebSocket、数据库、状态管理、UI通知 +- 与SQLite深度耦合 + +#### 目标结构 +``` +src/data/ +├── datasources/ +│ ├── WebSocketClient.ts # WebSocket连接管理 +│ └── MessageDataSource.ts # 消息数据源 +├── repositories/ +│ └── MessageRepository.ts # 消息仓库实现 +└── mappers/ + └── MessageMapper.ts # 消息数据映射 + +src/core/ +├── entities/ +│ └── Message.ts # 消息实体 +├── repositories/ +│ └── IMessageRepository.ts # 仓库接口 +└── usecases/ + ├── ProcessMessageUseCase.ts + ├── SyncMessagesUseCase.ts + └── UpdateReadReceiptUseCase.ts + +src/presentation/stores/ +└── messageStore.ts # 简化的消息状态管理 +``` + +#### 重构步骤 +1. 提取 WebSocketClient - 纯连接管理 +2. 创建 MessageRepository - 数据访问抽象 +3. 创建 UseCases - 业务逻辑 +4. 重写 messageStore - 仅负责UI状态 +5. 删除旧的 messageManager.ts + +### 2.2 乐观更新工具函数方案 + +#### 现状问题 +- userStore.ts 中重复6+次相同模式 +- 代码冗余,难以维护 + +#### 目标API +```typescript +// utils/optimisticUpdate.ts +export interface OptimisticUpdateOptions { + store: StoreApi; + getCurrentState: () => T; + optimisticUpdate: (current: T) => T; + apiCall: () => Promise; + onSuccess?: (result: R, current: T) => T; + onError?: (error: any, original: T) => void; +} + +export async function optimisticUpdate( + options: OptimisticUpdateOptions +): Promise +``` + +#### 使用示例 +```typescript +// 重构前(重复代码) +set(state => ({ posts: updatedPosts })); +try { + const result = await postService.like(postId); + // ... +} catch (error) { + set(state => ({ posts: originalPosts })); +} + +// 重构后(简洁) +await optimisticUpdate({ + store, + getCurrentState: () => store.getState().posts, + optimisticUpdate: (posts) => updatePostLikes(posts, postId, true), + apiCall: () => postService.like(postId), + onSuccess: (result, posts) => updatePostWithServerData(posts, result), +}); +``` + +### 2.3 useResponsive.ts 拆分方案 + +#### 现状问题 +- 485行,43个导出 +- 功能混杂:断点检测、响应式值计算、平台检测 + +#### 目标结构 +``` +src/presentation/hooks/responsive/ +├── useBreakpoint.ts # 断点检测 +├── useResponsiveValue.ts # 响应式值映射 +├── useOrientation.ts # 方向检测 +├── usePlatform.ts # 平台检测 +├── useScreenSize.ts # 屏幕尺寸 +└── index.ts # 统一导出 +``` + +#### 各Hook职责 +- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide) +- `useResponsiveValue`: 根据断点返回不同值 +- `useOrientation`: 返回 portrait/landscape +- `usePlatform`: 返回 ios/android/web +- `useScreenSize`: 返回 width/height + +### 2.4 MainNavigator 解耦方案 + +#### 现状问题 +- 1118行,直接依赖store +- 混合了导航配置和业务逻辑 + +#### 目标结构 +``` +src/infrastructure/navigation/ +├── MainNavigator.tsx # 简化后的导航器 +├── AuthNavigator.tsx # 认证导航 +├── TabNavigator.tsx # 标签导航 +├── navigationService.ts # 导航服务(解耦层) +├── navigationTypes.ts # 类型定义 +└── hooks/ + └── useNavigationState.ts # 导航状态hook +``` + +#### 解耦策略 +1. 创建 NavigationService - 提供导航操作的抽象 +2. Store通过NavigationService触发导航,而非直接操作 +3. MainNavigator只负责渲染,不处理业务逻辑 + +### 2.5 Repository 层创建方案 + +#### 现状问题 +- 服务层直接操作数据库 +- 没有明确的数据访问边界 + +#### 目标结构 +``` +src/data/repositories/ +├── MessageRepository.ts # 消息数据访问 +├── PostRepository.ts # 帖子数据访问 +├── UserRepository.ts # 用户数据访问 +└── interfaces/ + ├── IMessageRepository.ts + ├── IPostRepository.ts + └── IUserRepository.ts +``` + +#### 职责划分 +- **Repository**: 数据访问抽象,定义接口 +- **DataSource**: 具体数据源实现(API、SQLite、Cache) +- **Service**: 业务逻辑编排,使用Repository + +## 3. 实施计划 + +### 阶段1:基础设施(第1-2天) +- [ ] 创建目录结构 +- [ ] 实现乐观更新工具函数 +- [ ] 拆分 useResponsive hooks + +### 阶段2:数据层重构(第3-5天) +- [ ] 创建 Repository 接口和实现 +- [ ] 创建 DataSource 层 +- [ ] 迁移数据库操作到 Repository + +### 阶段3:核心业务重构(第6-10天) +- [ ] 重构 messageManager → UseCases + Store +- [ ] 创建 WebSocketClient +- [ ] 实现消息同步逻辑 + +### 阶段4:表现层重构(第11-12天) +- [ ] 解耦 MainNavigator +- [ ] 重构 userStore 使用乐观更新工具 +- [ ] 更新所有屏幕组件 + +### 阶段5:测试与优化(第13-14天) +- [ ] 单元测试 +- [ ] 集成测试 +- [ ] 性能优化 + +## 4. 风险与应对 + +| 风险 | 影响 | 应对措施 | +|-----|------|---------| +| 重构引入bug | 高 | 每个模块重构后必须测试,保持API兼容 | +| 开发时间超期 | 中 | 分阶段交付,优先核心功能 | +| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 | +| 性能下降 | 低 | 添加性能监控,及时优化 | + +## 5. 验收标准 + +- [ ] messageManager.ts 被删除,功能拆分到多个小模块 +- [ ] userStore.ts 中无重复乐观更新代码 +- [ ] useResponsive.ts 拆分为5+个专注hook +- [ ] MainNavigator.tsx 行数 < 300 +- [ ] 所有单元测试通过 +- [ ] 无TypeScript错误 +- [ ] 性能不劣化(启动时间、内存占用) diff --git a/package-lock.json b/package-lock.json index 4756a76..bf7231f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1914,14 +1914,14 @@ "version": "9.3.0", "resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz", "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/@hapi/topo/-/topo-5.1.0.tgz", "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" @@ -2128,7 +2128,7 @@ "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2142,7 +2142,7 @@ "version": "2.0.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2152,7 +2152,7 @@ "version": "1.2.8", "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2665,7 +2665,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli/-/cli-20.1.2.tgz", "integrity": "sha512-48GRnGfm1+4ueV8ESNJmKAKW01QdbB8H6qrVxCqpHYvuRkeFBaPpwRY2bEjSwqruw3Pn9ppzGfpAxYQDiUWQxQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-clean": "20.1.2", @@ -2695,7 +2695,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-clean/-/cli-clean-20.1.2.tgz", "integrity": "sha512-XcNlmFnYOVDjvHQQn0qreI4FPLEUx8p43EdOmKbtzqwqc9T/EdBdzUnUc5wWQPO1CN7BdMfxW8fyvosz8NIlrg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.2", @@ -2708,7 +2708,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config/-/cli-config-20.1.2.tgz", "integrity": "sha512-7aPE14QA8aXpfuQ1jmfiBfjC/N6gdbg6PpBTwb3cl8c/KaeVm+OQYoC2kn2b3XS0NPgw5Ix/VxVaX6AAUQRFuA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.2", @@ -2723,7 +2723,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-android/-/cli-config-android-20.1.2.tgz", "integrity": "sha512-W0Qx+lW8pbQMz8x3Rlf/H7D2j2u8O+u9HnrZnKzDl1DaXgaOQqL484lTZlMEQofjq7eLXdmzWxuZdqS6K1QfmQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.2", @@ -2736,7 +2736,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.2.tgz", "integrity": "sha512-Dhi1N1EoMMmJ4dnDlmNWCrJggfv7X/kl3l8uax72uaxepQI/CfohJP2rBdG2mWis+vzrCIk14z2keY0ixxsN8g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.2", @@ -2749,7 +2749,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-doctor/-/cli-doctor-20.1.2.tgz", "integrity": "sha512-bbT1EhomvHz5ZtzxY2czA4/JMXhP4aIAxRDsqiW6wfZA9A9/HXqA4uv6CxP0wZUUmovmPNRl3kW/LWXrRmdv3A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-config": "20.1.2", @@ -2773,7 +2773,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -2786,7 +2786,7 @@ "version": "4.1.0", "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -2803,7 +2803,7 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2813,7 +2813,7 @@ "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -2829,7 +2829,7 @@ "version": "5.4.1", "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -2853,7 +2853,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -2867,7 +2867,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.2.tgz", "integrity": "sha512-1iHB8cTTJpMyEKhxWTRYsxhBBsiOq3tVguGX/HtBdHRzhlCeDpanE6U+aKxWfMFetMcFL+HLe5nQPcJXf9EtKg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-config-android": "20.1.2", @@ -2881,7 +2881,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.2.tgz", "integrity": "sha512-UvzjcRGotO3E2xaty8YWE2XMGkkDDaXRtQtNRjzmtcoNY40C+y4iMHxd0o3xbD0bzYM/PO79tXye9MxTWdyVkg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-config-apple": "20.1.2", @@ -2895,7 +2895,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.2.tgz", "integrity": "sha512-ZzdLwJMt7ehjO0iy/rQGPgH6uZqMYXeS5uXzSi1DeLYwurV1wOqFc0SLm4TAz5FKYQmHpwBXlMiI12rUmkZxcg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-platform-apple": "20.1.2" @@ -2905,7 +2905,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-server-api/-/cli-server-api-20.1.2.tgz", "integrity": "sha512-ZlINtIYoDAwSemwTU9OavI1IixCCmAPPw1s3Mp0cOvrddFSZ0hx1N1IR+imLyo4lhFfM8OO3rUe9oVJj1SHUCA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.2", @@ -2925,7 +2925,7 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-1.1.0.tgz", "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -2935,7 +2935,7 @@ "version": "6.4.0", "resolved": "https://registry.npmmirror.com/open/-/open-6.4.0.tgz", "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-wsl": "^1.1.0" @@ -2948,7 +2948,7 @@ "version": "6.2.3", "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz", "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -2958,7 +2958,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.2.tgz", "integrity": "sha512-on2VUBZb68RlMxvIrEdK6+NiOEYu/z+t/cz746yGtxn49fwW6Wafzmh1QNZj8HPAuZ8+Ds61LiXbwoDDkzNSSA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@vscode/sudo-prompt": "^9.0.0", @@ -2977,7 +2977,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -2990,7 +2990,7 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -3007,7 +3007,7 @@ "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -3023,7 +3023,7 @@ "version": "4.1.0", "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -3040,7 +3040,7 @@ "version": "2.6.0", "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -3053,7 +3053,7 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3063,7 +3063,7 @@ "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -3079,7 +3079,7 @@ "version": "5.4.1", "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -3103,7 +3103,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -3119,7 +3119,7 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -3135,7 +3135,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -3149,7 +3149,7 @@ "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-types/-/cli-types-20.1.2.tgz", "integrity": "sha512-WYK98VdcJE+lRuyRzigE/GQAbaJZOKkjpaLwhmMMItXVTqMmIccfGu9b4pRoQOVfs1aLq87DuwUOi9sxz6OG1g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "joi": "^17.2.1" @@ -3159,7 +3159,7 @@ "version": "9.5.0", "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || >=14" @@ -3169,7 +3169,7 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -3186,7 +3186,7 @@ "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -3202,7 +3202,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -3218,7 +3218,7 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -3627,7 +3627,7 @@ "version": "4.1.5", "resolved": "https://registry.npmmirror.com/@sideway/address/-/address-4.1.5.tgz", "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" @@ -3637,14 +3637,14 @@ "version": "3.0.1", "resolved": "https://registry.npmmirror.com/@sideway/formula/-/formula-3.0.1.tgz", "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { @@ -3790,7 +3790,7 @@ "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3848,7 +3848,7 @@ "version": "9.3.2", "resolved": "https://registry.npmmirror.com/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@xmldom/xmldom": { @@ -3943,7 +3943,7 @@ "version": "0.2.1", "resolved": "https://registry.npmmirror.com/ansi-fragments/-/ansi-fragments-0.2.1.tgz", "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "colorette": "^1.0.7", @@ -3955,7 +3955,7 @@ "version": "4.1.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3965,7 +3965,7 @@ "version": "5.2.0", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^4.1.0" @@ -4015,7 +4015,7 @@ "version": "1.2.7", "resolved": "https://registry.npmmirror.com/appdirsjs/-/appdirsjs-1.2.7.tgz", "integrity": "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/arg": { @@ -4055,7 +4055,7 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-1.0.0.tgz", "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -4065,7 +4065,7 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/asynckit": { @@ -4355,7 +4355,7 @@ "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4367,7 +4367,7 @@ "version": "2.2.2", "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -4392,7 +4392,7 @@ "version": "2.4.1", "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -4492,7 +4492,7 @@ "version": "5.7.1", "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -4545,7 +4545,7 @@ "version": "1.0.4", "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4562,7 +4562,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4752,7 +4752,7 @@ "version": "1.4.0", "resolved": "https://registry.npmmirror.com/colorette/-/colorette-1.4.0.tgz", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -4771,7 +4771,7 @@ "version": "1.2.9", "resolved": "https://registry.npmmirror.com/command-exists/-/command-exists-1.2.9.tgz", "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -4877,7 +4877,7 @@ "version": "1.0.5", "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4906,7 +4906,7 @@ "version": "9.0.1", "resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -4933,14 +4933,14 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "devOptional": true, + "dev": true, "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4985,7 +4985,7 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/date-fns": { @@ -5002,7 +5002,7 @@ "version": "1.11.19", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -5026,7 +5026,7 @@ "version": "1.2.0", "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5165,7 +5165,7 @@ "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5175,7 +5175,7 @@ "version": "7.21.0", "resolved": "https://registry.npmmirror.com/envinfo/-/envinfo-7.21.0.tgz", "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "envinfo": "dist/cli.js" @@ -5188,7 +5188,7 @@ "version": "1.3.4", "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -5198,7 +5198,7 @@ "version": "0.2.1", "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/error-stack-parser": { @@ -5214,7 +5214,7 @@ "version": "1.5.2", "resolved": "https://registry.npmmirror.com/errorhandler/-/errorhandler-1.5.2.tgz", "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -5335,7 +5335,7 @@ "version": "5.1.1", "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -5359,7 +5359,7 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5369,7 +5369,7 @@ "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -5629,21 +5629,6 @@ "react-native": "*" } }, - "node_modules/expo-linking": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-linking/-/expo-linking-55.0.7.tgz", - "integrity": "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "expo-constants": "~55.0.7", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-manifests": { "version": "55.0.9", "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-55.0.9.tgz", @@ -6264,7 +6249,7 @@ "version": "3.3.3", "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6287,7 +6272,7 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -6300,7 +6285,7 @@ "version": "5.4.2", "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -6320,7 +6305,7 @@ "version": "1.20.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6511,7 +6496,7 @@ "version": "8.1.0", "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -6628,7 +6613,7 @@ "version": "6.0.1", "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6667,7 +6652,7 @@ "version": "5.1.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6842,7 +6827,7 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -6858,7 +6843,7 @@ "version": "0.7.2", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6875,7 +6860,7 @@ "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -6920,7 +6905,7 @@ "version": "3.3.1", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -6937,7 +6922,7 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -7027,7 +7012,7 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7046,7 +7031,7 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7059,7 +7044,7 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7087,7 +7072,7 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7100,7 +7085,7 @@ "version": "0.1.0", "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7344,7 +7329,7 @@ "version": "17.13.3", "resolved": "https://registry.npmmirror.com/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -7395,7 +7380,7 @@ "version": "2.3.1", "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/json5": { @@ -7414,7 +7399,7 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "devOptional": true, + "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -7442,7 +7427,7 @@ "version": "2.13.1", "resolved": "https://registry.npmmirror.com/launch-editor/-/launch-editor-2.13.1.tgz", "integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", @@ -7736,7 +7721,7 @@ "version": "1.2.4", "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -7850,7 +7835,7 @@ "version": "0.7.1", "resolved": "https://registry.npmmirror.com/logkitty/-/logkitty-0.7.1.tgz", "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-fragments": "^0.2.1", @@ -7865,7 +7850,7 @@ "version": "5.3.1", "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7875,7 +7860,7 @@ "version": "6.0.0", "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -7887,7 +7872,7 @@ "version": "6.2.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7902,14 +7887,14 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/logkitty/node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "cliui": "^6.0.0", @@ -7932,7 +7917,7 @@ "version": "18.1.3", "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -7991,7 +7976,7 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8025,7 +8010,7 @@ "version": "1.4.1", "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8434,7 +8419,7 @@ "version": "3.0.4", "resolved": "https://registry.npmmirror.com/nocache/-/nocache-3.0.4.tgz", "integrity": "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -8485,7 +8470,7 @@ "version": "1.15.0", "resolved": "https://registry.npmmirror.com/node-stream-zip/-/node-stream-zip-1.15.0.tgz", "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8523,7 +8508,7 @@ "version": "4.0.1", "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -8563,7 +8548,7 @@ "version": "1.13.4", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8779,7 +8764,7 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -8792,7 +8777,7 @@ "version": "5.2.0", "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -9046,7 +9031,7 @@ "version": "6.15.0", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -9089,7 +9074,7 @@ "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -9119,7 +9104,7 @@ "version": "3.0.2", "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -9712,7 +9697,7 @@ "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9795,7 +9780,7 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/resolve": { @@ -9850,7 +9835,7 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -9926,7 +9911,7 @@ "version": "1.2.0", "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -9970,7 +9955,7 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -10112,7 +10097,7 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/setimmediate": { @@ -10179,7 +10164,7 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10199,7 +10184,7 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10216,7 +10201,7 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10235,7 +10220,7 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10296,7 +10281,7 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-2.1.0.tgz", "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^3.2.0", @@ -10311,7 +10296,7 @@ "version": "3.2.1", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^1.9.0" @@ -10324,7 +10309,7 @@ "version": "1.9.3", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "color-name": "1.1.3" @@ -10334,14 +10319,14 @@ "version": "1.1.3", "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -10478,14 +10463,14 @@ "version": "0.0.1", "resolved": "https://registry.npmmirror.com/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz", "integrity": "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -10521,7 +10506,7 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10531,7 +10516,7 @@ "version": "2.2.0", "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.0.tgz", "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -10765,7 +10750,7 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -10780,7 +10765,7 @@ "version": "1.54.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10790,7 +10775,7 @@ "version": "3.0.2", "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -10807,7 +10792,7 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10899,7 +10884,7 @@ "version": "0.1.2", "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -11009,7 +10994,7 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -11138,7 +11123,7 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/wrap-ansi": { @@ -11303,7 +11288,7 @@ "version": "0.1.0", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/src/core/entities/Message.ts b/src/core/entities/Message.ts new file mode 100644 index 0000000..1fd66e6 --- /dev/null +++ b/src/core/entities/Message.ts @@ -0,0 +1,144 @@ +/** + * Message Entity - 消息领域实体 + * 定义消息的核心属性和行为,不依赖任何外部框架 + */ + +export interface MessageSegment { + type: 'text' | 'image' | 'mention' | 'reply' | string; + data: Record; +} + +export interface Message { + id: string; + conversationId: string; + senderId: string; + seq: number; + segments: MessageSegment[]; + createdAt: string; + status: 'normal' | 'recalled' | 'deleted'; + category?: string; + sender?: { + id: string; + username: string; + avatar?: string; + nickname?: string; + }; +} + +export interface Conversation { + id: string; + type: 'private' | 'group'; + isPinned: boolean; + lastSeq: number; + lastMessage?: Message; + lastMessageAt: string; + unreadCount: number; + participants: Array<{ + id: string; + username: string; + avatar?: string; + nickname?: string; + }>; + group?: { + id: string; + name: string; + avatar?: string; + }; + createdAt: string; + updatedAt: string; +} + +export interface UnreadCount { + total: number; + system: number; +} + +export interface TypingStatus { + groupId: string; + userId: string; + isTyping: boolean; +} + +export interface GroupNotice { + type: 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted'; + groupId: string; + data: { + userId?: string; + operatorId?: string; + role?: string; + username?: string; + [key: string]: any; + }; + timestamp: number; + messageId?: string; + seq?: number; +} + +// 工厂函数 +export const createMessage = (data: Partial): Message => ({ + id: data.id || '', + conversationId: data.conversationId || '', + senderId: data.senderId || '', + seq: data.seq || 0, + segments: data.segments || [], + createdAt: data.createdAt || new Date().toISOString(), + status: data.status || 'normal', + category: data.category, + sender: data.sender, +}); + +export const createConversation = (data: Partial): Conversation => ({ + id: data.id || '', + type: data.type || 'private', + isPinned: data.isPinned || false, + lastSeq: data.lastSeq || 0, + lastMessage: data.lastMessage, + lastMessageAt: data.lastMessageAt || new Date().toISOString(), + unreadCount: data.unreadCount || 0, + participants: data.participants || [], + group: data.group, + createdAt: data.createdAt || new Date().toISOString(), + updatedAt: data.updatedAt || new Date().toISOString(), +}); + +// 工具函数 +export const isMessageFromCurrentUser = (message: Message, currentUserId: string): boolean => { + return message.senderId === currentUserId; +}; + +export const shouldIncrementUnread = ( + message: Message, + currentUserId: string, + activeConversationId: string | null +): boolean => { + return ( + message.senderId !== currentUserId && + message.conversationId !== activeConversationId && + !!currentUserId + ); +}; + +export const buildTextContent = (segments: MessageSegment[]): string => { + return segments + .filter((s) => s.type === 'text') + .map((s) => s.data?.text || '') + .join(''); +}; + +export const buildGroupNoticeText = (notice: GroupNotice): string => { + const username = notice.data?.username || '用户'; + switch (notice.type) { + case 'member_join': + return `"${username}" 加入了群聊`; + case 'member_leave': + return `"${username}" 退出了群聊`; + case 'member_removed': + return `"${username}" 被移出群聊`; + case 'muted': + return `"${username}" 已被管理员禁言`; + case 'unmuted': + return `"${username}" 已被管理员解除禁言`; + default: + return ''; + } +}; diff --git a/src/core/usecases/ProcessMessageUseCase.ts b/src/core/usecases/ProcessMessageUseCase.ts new file mode 100644 index 0000000..2deb33c --- /dev/null +++ b/src/core/usecases/ProcessMessageUseCase.ts @@ -0,0 +1,656 @@ +/** + * ProcessMessageUseCase - 处理消息用例 + * 处理消息的业务逻辑,协调 Repository 和 WebSocketClient + * 纯业务逻辑,无UI依赖 + */ + +import { messageRepository } from '../../data/repositories/MessageRepository'; +import { webSocketClient, WebSocketEventType } from '../../data/datasources/WebSocketClient'; +import { + Message, + Conversation, + GroupNotice, + createMessage, + isMessageFromCurrentUser, + shouldIncrementUnread, + buildGroupNoticeText, +} from '../entities/Message'; +import { messageService } from '../../services/messageService'; +import { api } from '../../services/api'; + +// 事件类型定义 +export type MessageUseCaseEventType = + | 'message_received' + | 'message_recalled' + | 'typing_status' + | 'group_notice' + | 'connection_changed' + | 'error'; + +export interface MessageUseCaseEvent { + type: MessageUseCaseEventType; + payload: any; + timestamp: number; +} + +export type MessageUseCaseSubscriber = (event: MessageUseCaseEvent) => void; + +// 已读状态保护记录 +interface ReadStateRecord { + timestamp: number; + version: number; + lastReadSeq: number; + clearTimer?: NodeJS.Timeout; +} + +class ProcessMessageUseCase { + private subscribers: Set = new Set(); + private unsubscribeFns: Array<() => void> = []; + private processedMessageIds: Set = new Set(); + private processedMessageIdsExpiry: Map = new Map(); + private pendingReadMap: Map = new Map(); + private readStateVersion = 0; + private pendingUserRequests: Map> = new Map(); + private currentUserId: string | null = null; + + // 常量 + private readonly READ_STATE_PROTECTION_DELAY = 5000; // 5秒 + private readonly MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟 + + /** + * 初始化用例 + */ + initialize(currentUserId: string | null): void { + this.currentUserId = currentUserId; + this.setupWebSocketListeners(); + } + + /** + * 销毁用例 + */ + destroy(): void { + this.unsubscribeFns.forEach((fn) => fn()); + this.unsubscribeFns = []; + this.subscribers.clear(); + this.processedMessageIds.clear(); + this.processedMessageIdsExpiry.clear(); + this.pendingReadMap.clear(); + this.pendingUserRequests.clear(); + } + + /** + * 设置WebSocket监听器 + */ + private setupWebSocketListeners(): void { + // 监听私聊消息 + const unsubChat = webSocketClient.on('chat', (message) => { + this.handleNewMessage(message); + }); + + // 监听群聊消息 + const unsubGroupMessage = webSocketClient.on('group_message', (message) => { + this.handleNewMessage(message); + }); + + // 监听私聊已读回执 + const unsubRead = webSocketClient.on('read', (message) => { + this.handleReadReceipt(message); + }); + + // 监听群聊已读回执 + const unsubGroupRead = webSocketClient.on('group_read', (message) => { + this.handleGroupReadReceipt(message); + }); + + // 监听私聊消息撤回 + const unsubRecall = webSocketClient.on('recall', (message) => { + this.handleRecallMessage(message); + }); + + // 监听群聊消息撤回 + const unsubGroupRecall = webSocketClient.on('group_recall', (message) => { + this.handleGroupRecallMessage(message); + }); + + // 监听群聊输入状态 + const unsubGroupTyping = webSocketClient.on('group_typing', (message) => { + this.handleGroupTyping(message); + }); + + // 监听群通知 + const unsubGroupNotice = webSocketClient.on('group_notice', (message) => { + this.handleGroupNotice(message); + }); + + // 监听连接状态 + const unsubConnected = webSocketClient.on('connected', () => { + this.notifySubscribers({ + type: 'connection_changed', + payload: { connected: true }, + timestamp: Date.now(), + }); + }); + + const unsubDisconnected = webSocketClient.on('disconnected', () => { + this.notifySubscribers({ + type: 'connection_changed', + payload: { connected: false }, + timestamp: Date.now(), + }); + }); + + this.unsubscribeFns = [ + unsubChat, + unsubGroupMessage, + unsubRead, + unsubGroupRead, + unsubRecall, + unsubGroupRecall, + unsubGroupTyping, + unsubGroupNotice, + unsubConnected, + unsubDisconnected, + ]; + } + + /** + * 处理新消息 + */ + private async handleNewMessage(message: any): Promise { + const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message; + + // 消息去重检查 + if (this.isMessageProcessed(id)) { + return; + } + this.markMessageAsProcessed(id); + + // 构造消息对象 + const newMessage = createMessage({ + id, + conversationId: String(conversation_id), + senderId: sender_id, + seq, + segments: segments || [], + createdAt: created_at, + }); + + // 异步获取发送者信息(如果是群聊且不是当前用户发送的) + if (message.type === 'group_message' && sender_id && sender_id !== this.currentUserId) { + this.enrichMessageWithSender(newMessage); + } + + // 异步保存到本地数据库 + const isRead = _isAck || sender_id === this.currentUserId; + messageRepository.saveMessage(newMessage, isRead).catch((error) => { + console.error('[ProcessMessageUseCase] 保存消息失败:', error); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'message_received', + payload: { + message: newMessage, + isCurrentUser: sender_id === this.currentUserId, + isAck: _isAck, + }, + timestamp: Date.now(), + }); + } + + /** + * 丰富消息的发送者信息 + */ + private async enrichMessageWithSender(message: Message): Promise { + try { + const user = await this.getSenderInfo(message.senderId); + if (user) { + message.sender = user; + // 重新通知,包含发送者信息 + this.notifySubscribers({ + type: 'message_received', + payload: { + message, + isCurrentUser: false, + isUpdated: true, + }, + timestamp: Date.now(), + }); + } + } catch (error) { + console.error('[ProcessMessageUseCase] 获取发送者信息失败:', error); + } + } + + /** + * 获取发送者信息(带缓存和去重) + */ + private async getSenderInfo(userId: string): Promise { + // 先检查本地缓存 + const cachedUser = await messageRepository.getUserCache(userId); + if (cachedUser) { + return cachedUser; + } + + // 检查是否已有正在进行的请求 + const pendingRequest = this.pendingUserRequests.get(userId); + if (pendingRequest) { + return pendingRequest; + } + + // 发起新请求 + const request = this.fetchUserInfo(userId); + this.pendingUserRequests.set(userId, request); + + try { + const user = await request; + return user; + } finally { + this.pendingUserRequests.delete(userId); + } + } + + /** + * 从服务器获取用户信息 + */ + private async fetchUserInfo(userId: string): Promise { + try { + const response = await api.get(`/users/${userId}`); + if (response.code === 0 && response.data) { + await messageRepository.saveUserCache(response.data); + return response.data; + } + return null; + } catch (error) { + console.error(`[ProcessMessageUseCase] 获取用户信息失败: ${userId}`, error); + return null; + } + } + + /** + * 检查消息是否已处理 + */ + private isMessageProcessed(messageId: string): boolean { + return this.processedMessageIds.has(messageId); + } + + /** + * 标记消息已处理 + */ + private markMessageAsProcessed(messageId: string): void { + this.processedMessageIds.add(messageId); + this.processedMessageIdsExpiry.set(messageId, Date.now()); + + // 定期清理 + if (this.processedMessageIds.size > 1000) { + this.cleanupProcessedMessageIds(); + } + } + + /** + * 清理过期的消息ID + */ + private cleanupProcessedMessageIds(): void { + const now = Date.now(); + for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) { + if (now - timestamp > this.MESSAGE_ID_EXPIRY) { + this.processedMessageIds.delete(id); + this.processedMessageIdsExpiry.delete(id); + } + } + } + + /** + * 处理私聊已读回执 + */ + private handleReadReceipt(message: any): void { + // 可以在这里处理对方已读的状态更新 + } + + /** + * 处理群聊已读回执 + */ + private handleGroupReadReceipt(message: any): void { + // 可以在这里处理群聊已读状态更新 + } + + /** + * 处理私聊消息撤回 + */ + private handleRecallMessage(message: any): Promise { + const { conversation_id, message_id } = message; + + // 更新本地数据库状态 + messageRepository + .updateMessageStatus(message_id, 'recalled', true) + .catch((error) => { + console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'message_recalled', + payload: { + conversationId: String(conversation_id), + messageId: message_id, + isGroup: false, + }, + timestamp: Date.now(), + }); + + return Promise.resolve(); + } + + /** + * 处理群聊消息撤回 + */ + private handleGroupRecallMessage(message: any): Promise { + const { conversation_id, message_id } = message; + + // 更新本地数据库状态 + messageRepository + .updateMessageStatus(message_id, 'recalled', true) + .catch((error) => { + console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'message_recalled', + payload: { + conversationId: String(conversation_id), + messageId: message_id, + isGroup: true, + }, + timestamp: Date.now(), + }); + + return Promise.resolve(); + } + + /** + * 处理群聊输入状态 + */ + private handleGroupTyping(message: any): void { + const { group_id, user_id, is_typing } = message; + + this.notifySubscribers({ + type: 'typing_status', + payload: { + groupId: String(group_id), + userId: user_id, + isTyping: is_typing, + }, + timestamp: Date.now(), + }); + } + + /** + * 处理群通知 + */ + private handleGroupNotice(message: any): void { + const { notice_type, group_id, data, timestamp, message_id, seq } = message; + const groupIdStr = String(group_id); + + const notice: GroupNotice = { + type: notice_type, + groupId: groupIdStr, + data: data || {}, + timestamp: timestamp || Date.now(), + messageId, + seq, + }; + + // 通知订阅者 + this.notifySubscribers({ + type: 'group_notice', + payload: { + notice, + text: buildGroupNoticeText(notice), + }, + timestamp: Date.now(), + }); + } + + /** + * 标记会话已读 + */ + async markAsRead(conversationId: string, seq: number): Promise { + const existingRecord = this.pendingReadMap.get(conversationId); + + // 使用seq去重 + if (existingRecord && seq <= existingRecord.lastReadSeq) { + return; + } + + // 清除可能存在的旧定时器 + if (existingRecord?.clearTimer) { + clearTimeout(existingRecord.clearTimer); + } + + // 递增全局版本号 + this.readStateVersion++; + const currentVersion = this.readStateVersion; + + // 记录已读状态 + this.pendingReadMap.set(conversationId, { + timestamp: Date.now(), + version: currentVersion, + lastReadSeq: seq, + }); + + // 更新本地数据库 + await messageRepository.markConversationAsRead(conversationId); + + // 调用API + try { + await messageService.markAsRead(conversationId, seq); + } catch (error) { + console.error('[ProcessMessageUseCase] 标记已读API失败:', error); + this.pendingReadMap.delete(conversationId); + throw error; + } + + // 设置延迟清除保护 + const clearTimer = setTimeout(() => { + const record = this.pendingReadMap.get(conversationId); + if (record && record.version === currentVersion) { + this.pendingReadMap.delete(conversationId); + } + }, this.READ_STATE_PROTECTION_DELAY); + + this.pendingReadMap.set(conversationId, { + timestamp: Date.now(), + version: currentVersion, + lastReadSeq: seq, + clearTimer, + }); + } + + /** + * 检查会话是否有进行中的已读请求 + */ + hasPendingRead(conversationId: string): boolean { + return this.pendingReadMap.has(conversationId); + } + + /** + * 获取进行中的已读记录 + */ + getPendingReadRecord(conversationId: string): ReadStateRecord | undefined { + return this.pendingReadMap.get(conversationId); + } + + /** + * 发送消息 + */ + async sendMessage( + conversationId: string, + segments: any[], + options?: { replyToId?: string } + ): Promise { + try { + const response = await messageService.sendMessage(conversationId, { + segments, + reply_to_id: options?.replyToId, + }); + + if (response) { + const message = createMessage({ + id: response.id, + conversationId, + senderId: this.currentUserId || '', + seq: response.seq, + segments, + createdAt: new Date().toISOString(), + }); + + // 保存到本地数据库 + await messageRepository.saveMessage(message, true); + + return message; + } + + return null; + } catch (error) { + console.error('[ProcessMessageUseCase] 发送消息失败:', error); + throw error; + } + } + + /** + * 获取本地消息 + */ + async getLocalMessages(conversationId: string, limit: number = 20): Promise { + return messageRepository.getMessagesByConversation(conversationId, limit); + } + + /** + * 获取历史消息 + */ + async getHistoryMessages( + conversationId: string, + beforeSeq: number, + limit: number = 20 + ): Promise { + // 先从本地获取 + const localMessages = await messageRepository.getMessagesBeforeSeq( + conversationId, + beforeSeq, + limit + ); + + if (localMessages.length >= limit) { + return localMessages; + } + + // 本地数据不足,从服务端获取 + try { + const response = await messageService.getMessages( + conversationId, + undefined, + beforeSeq, + limit + ); + + if (response?.messages && response.messages.length > 0) { + const serverMessages = response.messages.map((m: any) => + createMessage({ + id: m.id, + conversationId: m.conversation_id || conversationId, + senderId: m.sender_id, + seq: m.seq, + segments: m.segments || [], + createdAt: m.created_at, + status: m.status || 'normal', + }) + ); + + // 保存到本地 + await messageRepository.saveMessages(serverMessages, false); + + return serverMessages; + } + } catch (error) { + console.error('[ProcessMessageUseCase] 获取历史消息失败:', error); + } + + return localMessages; + } + + /** + * 从服务端同步消息 + */ + async syncMessagesFromServer( + conversationId: string, + afterSeq?: number + ): Promise { + try { + const response = await messageService.getMessages(conversationId, afterSeq); + + if (response?.messages && response.messages.length > 0) { + const messages = response.messages.map((m: any) => + createMessage({ + id: m.id, + conversationId: m.conversation_id || conversationId, + senderId: m.sender_id, + seq: m.seq, + segments: m.segments || [], + createdAt: m.created_at, + status: m.status || 'normal', + }) + ); + + // 保存到本地 + await messageRepository.saveMessages(messages, false); + + return messages; + } + } catch (error) { + console.error('[ProcessMessageUseCase] 同步消息失败:', error); + } + + return []; + } + + /** + * 订阅事件 + */ + subscribe(subscriber: MessageUseCaseSubscriber): () => void { + this.subscribers.add(subscriber); + + return () => { + this.subscribers.delete(subscriber); + }; + } + + /** + * 通知订阅者 + */ + private notifySubscribers(event: MessageUseCaseEvent): void { + this.subscribers.forEach((subscriber) => { + try { + subscriber(event); + } catch (error) { + console.error('[ProcessMessageUseCase] 订阅者执行失败:', error); + } + }); + } + + /** + * 获取当前用户ID + */ + getCurrentUserId(): string | null { + return this.currentUserId; + } + + /** + * 设置当前用户ID + */ + setCurrentUserId(userId: string | null): void { + this.currentUserId = userId; + } +} + +export const processMessageUseCase = new ProcessMessageUseCase(); +export default processMessageUseCase; diff --git a/src/data/datasources/ApiDataSource.ts b/src/data/datasources/ApiDataSource.ts new file mode 100644 index 0000000..f097e94 --- /dev/null +++ b/src/data/datasources/ApiDataSource.ts @@ -0,0 +1,103 @@ +/** + * API 数据源实现 + * 封装所有 API 调用,统一错误处理和请求拦截 + */ + +import { api, ApiResponse, ApiError } from '../../services/api'; +import { IApiDataSource, DataSourceError } from './interfaces'; + +export class ApiDataSource implements IApiDataSource { + private baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl || ''; + } + + private buildUrl(url: string): string { + if (url.startsWith('http')) { + return url; + } + return `${this.baseUrl}${url}`; + } + + private handleError(error: unknown, operation: string): never { + if (error instanceof ApiError) { + throw new DataSourceError( + error.message, + String(error.code), + 'ApiDataSource', + error + ); + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new DataSourceError( + `API ${operation} failed: ${message}`, + 'API_ERROR', + 'ApiDataSource', + error instanceof Error ? error : undefined + ); + } + + async get(url: string, params?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.get(fullUrl, params); + return response.data; + } catch (error) { + this.handleError(error, 'GET'); + } + } + + async post(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.post(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'POST'); + } + } + + async put(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.put(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'PUT'); + } + } + + async delete(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + // 处理带 request body 的 DELETE 请求 + if (data) { + const response = await api.request('DELETE', fullUrl, undefined, data); + return response.data; + } + const response = await api.delete(fullUrl); + return response.data; + } catch (error) { + this.handleError(error, 'DELETE'); + } + } + + async upload( + url: string, + file: { uri: string; name: string; type: string }, + additionalData?: Record + ): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.upload(fullUrl, file, additionalData); + return response.data; + } catch (error) { + this.handleError(error, 'UPLOAD'); + } + } +} + +// 导出单例实例 +export const apiDataSource = new ApiDataSource(); diff --git a/src/data/datasources/CacheDataSource.ts b/src/data/datasources/CacheDataSource.ts new file mode 100644 index 0000000..921e767 --- /dev/null +++ b/src/data/datasources/CacheDataSource.ts @@ -0,0 +1,199 @@ +/** + * 缓存数据源实现 + * 基于内存和 AsyncStorage 的混合缓存实现 + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { ICacheDataSource, DataSourceError } from './interfaces'; + +interface CacheEntry { + value: T; + expiry: number | null; // null 表示永不过期 +} + +export class CacheDataSource implements ICacheDataSource { + private memoryCache: Map> = new Map(); + private maxSize: number; + private defaultTtl: number; // 默认过期时间(毫秒) + private persistPrefix: string; + + constructor(config?: { + maxSize?: number; + defaultTtl?: number; // 默认5分钟 + persistPrefix?: string; + }) { + this.maxSize = config?.maxSize || 100; + this.defaultTtl = config?.defaultTtl || 5 * 60 * 1000; + this.persistPrefix = config?.persistPrefix || 'cache_'; + } + + /** + * 检查 key 是否过期 + */ + private isExpired(entry: CacheEntry): boolean { + if (entry.expiry === null) return false; + return Date.now() > entry.expiry; + } + + /** + * 清理过期缓存 + */ + private cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.memoryCache.entries()) { + if (entry.expiry !== null && now > entry.expiry) { + this.memoryCache.delete(key); + } + } + + // 如果仍然超过最大大小,删除最旧的条目 + while (this.memoryCache.size > this.maxSize) { + const firstKey = this.memoryCache.keys().next().value; + if (firstKey !== undefined) { + this.memoryCache.delete(firstKey); + } + } + } + + /** + * 生成持久化存储 key + */ + private getPersistKey(key: string): string { + return `${this.persistPrefix}${key}`; + } + + // ==================== ICacheDataSource 实现 ==================== + + async get(key: string): Promise { + try { + // 先检查内存缓存 + const memoryEntry = this.memoryCache.get(key); + if (memoryEntry) { + if (!this.isExpired(memoryEntry)) { + return memoryEntry.value as T; + } + // 过期了,从内存中删除 + this.memoryCache.delete(key); + } + + // 尝试从持久化存储读取 + const persistKey = this.getPersistKey(key); + const stored = await AsyncStorage.getItem(persistKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + if (!this.isExpired(entry)) { + // 重新加载到内存缓存 + this.memoryCache.set(key, entry); + this.cleanup(); + return entry.value; + } + // 过期了,删除 + await AsyncStorage.removeItem(persistKey); + } + + return null; + } catch (error) { + console.warn(`[CacheDataSource] Failed to get ${key}:`, error); + return null; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const expiry = ttl === undefined + ? (this.defaultTtl > 0 ? Date.now() + this.defaultTtl : null) + : (ttl > 0 ? Date.now() + ttl : null); + + const entry: CacheEntry = { value, expiry }; + + // 存入内存 + this.memoryCache.set(key, entry); + this.cleanup(); + + // 持久化存储(重要数据) + if (ttl === undefined || ttl > 0) { + const persistKey = this.getPersistKey(key); + await AsyncStorage.setItem(persistKey, JSON.stringify(entry)); + } + } catch (error) { + throw new DataSourceError( + `Failed to set cache ${key}`, + 'CACHE_SET_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async delete(key: string): Promise { + try { + // 从内存删除 + this.memoryCache.delete(key); + + // 从持久化存储删除 + const persistKey = this.getPersistKey(key); + await AsyncStorage.removeItem(persistKey); + } catch (error) { + throw new DataSourceError( + `Failed to delete cache ${key}`, + 'CACHE_DELETE_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async clear(): Promise { + try { + // 清空内存 + this.memoryCache.clear(); + + // 清空持久化存储中的所有缓存项 + const keys = await AsyncStorage.getAllKeys(); + const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix)); + if (cacheKeys.length > 0) { + await AsyncStorage.multiRemove(cacheKeys); + } + } catch (error) { + throw new DataSourceError( + 'Failed to clear cache', + 'CACHE_CLEAR_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async has(key: string): Promise { + try { + // 检查内存 + const memoryEntry = this.memoryCache.get(key); + if (memoryEntry && !this.isExpired(memoryEntry)) { + return true; + } + + // 检查持久化存储 + const persistKey = this.getPersistKey(key); + const stored = await AsyncStorage.getItem(persistKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + return !this.isExpired(entry); + } + + return false; + } catch (error) { + return false; + } + } + + async getMultiple(keys: string[]): Promise<(T | null)[]> { + return Promise.all(keys.map(key => this.get(key))); + } + + async setMultiple(entries: { key: string; value: T; ttl?: number }[]): Promise { + await Promise.all(entries.map(entry => this.set(entry.key, entry.value, entry.ttl))); + } +} + +// 导出单例实例 +export const cacheDataSource = new CacheDataSource(); diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts new file mode 100644 index 0000000..90ddc98 --- /dev/null +++ b/src/data/datasources/LocalDataSource.ts @@ -0,0 +1,281 @@ +/** + * 本地数据库数据源实现 + * 封装所有 SQLite 操作,提供统一的错误处理和事务支持 + */ + +import * as SQLite from 'expo-sqlite'; +import { ILocalDataSource, DataSourceError } from './interfaces'; + +// 数据库实例管理 +let dbInstance: SQLite.SQLiteDatabase | null = null; +let currentDbName: string | null = null; +let writeQueue: Promise = Promise.resolve(); + +export interface LocalDataSourceConfig { + dbName?: string; + userId?: string; +} + +export class LocalDataSource implements ILocalDataSource { + private db: SQLite.SQLiteDatabase | null = null; + private dbName: string; + private initialized = false; + + constructor(config: LocalDataSourceConfig = {}) { + // 如果提供了userId,使用用户专属数据库 + this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db'); + } + + /** + * 初始化数据库连接 + */ + async initialize(): Promise { + if (this.initialized && this.db) { + return; + } + + try { + // 使用全局实例管理 + if (dbInstance && currentDbName === this.dbName) { + this.db = dbInstance; + this.initialized = true; + return; + } + + // 关闭旧连接 + if (dbInstance) { + try { + await dbInstance.closeAsync(); + } catch (e) { + console.warn('关闭旧数据库连接失败:', e); + } + } + + // 创建新连接 + this.db = await SQLite.openDatabaseAsync(this.dbName); + dbInstance = this.db; + currentDbName = this.dbName; + this.initialized = true; + + // 初始化数据库表结构 + await this.createTables(); + } catch (error) { + this.handleError(error, 'INITIALIZE'); + } + } + + /** + * 创建数据库表结构 + */ + private async createTables(): Promise { + if (!this.db) return; + + const tables = [ + // 消息表 + `CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + conversationId TEXT NOT NULL, + senderId TEXT NOT NULL, + content TEXT, + type TEXT DEFAULT 'text', + isRead INTEGER DEFAULT 0, + createdAt TEXT NOT NULL, + seq INTEGER DEFAULT 0, + status TEXT DEFAULT 'normal', + segments TEXT + )`, + // 会话表 + `CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY NOT NULL, + participantId TEXT NOT NULL, + lastMessageId TEXT, + lastSeq INTEGER DEFAULT 0, + unreadCount INTEGER DEFAULT 0, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 会话缓存表 + `CREATE TABLE IF NOT EXISTS conversation_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 会话列表缓存表 + `CREATE TABLE IF NOT EXISTS conversation_list_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 用户缓存表 + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 当前登录用户缓存 + `CREATE TABLE IF NOT EXISTS current_user_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 群组缓存表 + `CREATE TABLE IF NOT EXISTS groups ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 群成员缓存表 + `CREATE TABLE IF NOT EXISTS group_members ( + groupId TEXT NOT NULL, + userId TEXT NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (groupId, userId) + )`, + ]; + + for (const sql of tables) { + await this.db.execAsync(sql); + } + + // 创建索引 + const indexes = [ + `CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`, + `CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`, + `CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`, + `CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`, + ]; + + for (const sql of indexes) { + await this.db.execAsync(sql); + } + } + + /** + * 处理错误 + */ + private handleError(error: unknown, operation: string): never { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new DataSourceError( + `Database ${operation} failed: ${message}`, + 'DB_ERROR', + 'LocalDataSource', + error instanceof Error ? error : undefined + ); + } + + /** + * 检查数据库连接 + */ + private ensureDb(): SQLite.SQLiteDatabase { + if (!this.db) { + throw new DataSourceError( + 'Database not initialized', + 'DB_NOT_INITIALIZED', + 'LocalDataSource' + ); + } + return this.db; + } + + // ==================== ILocalDataSource 实现 ==================== + + async query(sql: string, params?: any[]): Promise { + try { + const db = this.ensureDb(); + return await db.getAllAsync(sql, params); + } catch (error) { + this.handleError(error, 'QUERY'); + } + } + + async getFirst(sql: string, params?: any[]): Promise { + try { + const db = this.ensureDb(); + return await db.getFirstAsync(sql, params); + } catch (error) { + this.handleError(error, 'GET_FIRST'); + } + } + + async execute(sql: string, params?: any[]): Promise { + try { + const db = this.ensureDb(); + await db.execAsync(sql); + } catch (error) { + this.handleError(error, 'EXECUTE'); + } + } + + async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { + try { + const db = this.ensureDb(); + return await db.runAsync(sql, params); + } catch (error) { + this.handleError(error, 'RUN'); + } + } + + async transaction(operations: () => Promise): Promise { + try { + const db = this.ensureDb(); + await db.withTransactionAsync(async () => { + await operations(); + }); + } catch (error) { + this.handleError(error, 'TRANSACTION'); + } + } + + async batch(operations: (db: ILocalDataSource) => Promise): Promise { + try { + await this.transaction(async () => { + await operations(this); + }); + } catch (error) { + this.handleError(error, 'BATCH'); + } + } + + // ==================== 队列写入支持 ==================== + + /** + * 使用队列执行写入操作(避免并发写入冲突) + */ + async enqueueWrite(operation: () => Promise): Promise { + const wrappedOperation = async () => { + try { + return await operation(); + } catch (error) { + // 尝试重连后重试 + if (this.isRecoverableError(error)) { + console.error('数据库写入异常,尝试重连后重试:', error); + this.db = null; + dbInstance = null; + await this.initialize(); + return operation(); + } + throw error; + } + }; + + const queued = writeQueue.then(wrappedOperation, wrappedOperation); + writeQueue = queued.then(() => undefined, () => undefined); + return queued; + } + + /** + * 判断错误是否可恢复 + */ + private isRecoverableError(error: unknown): boolean { + const message = String(error); + return ( + message.includes('NativeDatabase.prepareAsync') || + message.includes('NullPointerException') || + message.includes('database is locked') + ); + } +} + +// 导出单例实例 +export const localDataSource = new LocalDataSource(); diff --git a/src/data/datasources/WebSocketClient.ts b/src/data/datasources/WebSocketClient.ts new file mode 100644 index 0000000..8c7398e --- /dev/null +++ b/src/data/datasources/WebSocketClient.ts @@ -0,0 +1,184 @@ +/** + * WebSocketClient - WebSocket连接管理 + * 只负责WebSocket连接管理,提供事件驱动架构 + */ + +import { + sseService, + WSChatMessage, + WSGroupChatMessage, + WSReadMessage, + WSGroupReadMessage, + WSRecallMessage, + WSGroupRecallMessage, + WSGroupTypingMessage, + WSGroupNoticeMessage, + WSMessageType, +} from '../../services/sseService'; + +// 事件处理器类型 +type EventHandler = (data: T) => void; + +// WebSocket事件类型 +export interface WebSocketEvents { + 'chat': WSChatMessage; + 'group_message': WSGroupChatMessage; + 'read': WSReadMessage; + 'group_read': WSGroupReadMessage; + 'recall': WSRecallMessage; + 'group_recall': WSGroupRecallMessage; + 'group_typing': WSGroupTypingMessage; + 'group_notice': WSGroupNoticeMessage; + 'connected': void; + 'disconnected': void; + 'error': Error; +} + +export type WebSocketEventType = keyof WebSocketEvents; + +class WebSocketClient { + private handlers: Map>> = new Map(); + private unsubscribeFns: Array<() => void> = []; + private isInitialized = false; + + /** + * 初始化WebSocket监听 + */ + initialize(): void { + if (this.isInitialized) return; + + // 监听私聊消息 + const unsubChat = sseService.on('chat', (message) => { + this.emit('chat', message); + }); + + // 监听群聊消息 + const unsubGroupMessage = sseService.on('group_message', (message) => { + this.emit('group_message', message); + }); + + // 监听私聊已读回执 + const unsubRead = sseService.on('read', (message) => { + this.emit('read', message); + }); + + // 监听群聊已读回执 + const unsubGroupRead = sseService.on('group_read', (message) => { + this.emit('group_read', message); + }); + + // 监听私聊消息撤回 + const unsubRecall = sseService.on('recall', (message) => { + this.emit('recall', message); + }); + + // 监听群聊消息撤回 + const unsubGroupRecall = sseService.on('group_recall', (message) => { + this.emit('group_recall', message); + }); + + // 监听群聊输入状态 + const unsubGroupTyping = sseService.on('group_typing', (message) => { + this.emit('group_typing', message); + }); + + // 监听群通知 + const unsubGroupNotice = sseService.on('group_notice', (message) => { + this.emit('group_notice', message); + }); + + // 监听连接状态 + const unsubConnect = sseService.onConnect(() => { + this.emit('connected', undefined); + }); + + const unsubDisconnect = sseService.onDisconnect(() => { + this.emit('disconnected', undefined); + }); + + this.unsubscribeFns = [ + unsubChat, + unsubGroupMessage, + unsubRead, + unsubGroupRead, + unsubRecall, + unsubGroupRecall, + unsubGroupTyping, + unsubGroupNotice, + unsubConnect, + unsubDisconnect, + ]; + + this.isInitialized = true; + } + + /** + * 销毁WebSocket监听 + */ + destroy(): void { + this.unsubscribeFns.forEach((fn) => fn()); + this.unsubscribeFns = []; + this.handlers.clear(); + this.isInitialized = false; + } + + /** + * 订阅事件 + */ + on( + event: T, + handler: EventHandler + ): () => void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(handler); + + return () => { + this.handlers.get(event)?.delete(handler); + }; + } + + /** + * 触发事件 + */ + private emit( + event: T, + data: WebSocketEvents[T] + ): void { + const handlers = this.handlers.get(event); + if (handlers) { + handlers.forEach((handler) => { + try { + handler(data); + } catch (error) { + console.error(`[WebSocketClient] 事件处理器执行失败: ${event}`, error); + } + }); + } + } + + /** + * 检查连接状态 + */ + isConnected(): boolean { + return sseService.isConnected(); + } + + /** + * 启动WebSocket连接 + */ + async connect(): Promise { + return sseService.start(); + } + + /** + * 断开WebSocket连接 + */ + disconnect(): void { + sseService.stop(); + } +} + +export const webSocketClient = new WebSocketClient(); +export default webSocketClient; diff --git a/src/data/datasources/index.ts b/src/data/datasources/index.ts new file mode 100644 index 0000000..683b5ec --- /dev/null +++ b/src/data/datasources/index.ts @@ -0,0 +1,9 @@ +/** + * 数据源导出 + * 统一导出所有数据源实现 + */ + +export * from './interfaces'; +export * from './ApiDataSource'; +export * from './LocalDataSource'; +export * from './CacheDataSource'; diff --git a/src/data/datasources/interfaces.ts b/src/data/datasources/interfaces.ts new file mode 100644 index 0000000..c2c9b46 --- /dev/null +++ b/src/data/datasources/interfaces.ts @@ -0,0 +1,64 @@ +/** + * 数据源接口定义 + * 定义所有数据源的标准接口,便于替换和测试 + */ + +// API 数据源接口 +export interface IApiDataSource { + get(url: string, params?: any): Promise; + post(url: string, data?: any): Promise; + put(url: string, data?: any): Promise; + delete(url: string, data?: any): Promise; + upload(url: string, file: { uri: string; name: string; type: string }, additionalData?: Record): Promise; +} + +// 本地数据源接口 (SQLite) +export interface ILocalDataSource { + query(sql: string, params?: any[]): Promise; + getFirst(sql: string, params?: any[]): Promise; + execute(sql: string, params?: any[]): Promise; + run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>; + transaction(operations: () => Promise): Promise; + batch(operations: (db: ILocalDataSource) => Promise): Promise; +} + +// 缓存数据源接口 +export interface ICacheDataSource { + get(key: string): Promise; + set(key: string, value: T, ttl?: number): Promise; + delete(key: string): Promise; + clear(): Promise; + has(key: string): Promise; + getMultiple(keys: string[]): Promise<(T | null)[]>; + setMultiple(entries: { key: string; value: T; ttl?: number }[]): Promise; +} + +// 数据源错误类型 +export class DataSourceError extends Error { + constructor( + message: string, + public code: string, + public source: string, + public originalError?: Error + ) { + super(message); + this.name = 'DataSourceError'; + } +} + +// 数据源配置 +export interface DataSourceConfig { + api?: { + baseUrl: string; + timeout?: number; + retryCount?: number; + }; + local?: { + dbName: string; + version: number; + }; + cache?: { + maxSize: number; + defaultTtl: number; + }; +} diff --git a/src/data/mappers/ConversationMapper.ts b/src/data/mappers/ConversationMapper.ts new file mode 100644 index 0000000..4eaaca0 --- /dev/null +++ b/src/data/mappers/ConversationMapper.ts @@ -0,0 +1,290 @@ +/** + * 会话数据映射器 + * 负责 Conversation 模型与 API 响应、数据库记录之间的转换 + */ + +import { + ConversationModel, + ConversationType, + UserModel, + GroupModel, + MessageModel, +} from '../models'; +import type { + ConversationResponse, + ConversationDetailResponse, + UserDTO, + GroupResponse, + MessageResponse, +} from '../../types/dto'; +import { MessageMapper } from './MessageMapper'; + +// 数据库会话记录类型 +export interface ConversationDbRecord { + id: string; + participantId: string; + lastMessageId: string | null; + lastSeq: number; + unreadCount: number; + createdAt: string; + updatedAt: string; +} + +// 缓存数据类型 +export interface ConversationCacheData { + id: string; + type: string; + is_pinned?: boolean; + last_seq?: number; + last_message?: any; + last_message_at?: string; + unread_count?: number; + participants?: any[]; + my_last_read_seq?: number; + other_last_read_seq?: number; + group?: any; + created_at?: string; + updated_at?: string; +} + +export class ConversationMapper { + /** + * 将 API 列表响应转换为应用模型 + */ + static fromApiResponse(response: ConversationResponse): ConversationModel { + return { + id: String(response.id || ''), + type: (response.type as ConversationType) || 'private', + participantId: this.extractParticipantId(response), + lastMessageId: response.last_message?.id, + lastMessage: response.last_message + ? MessageMapper.fromApiResponse(response.last_message) + : undefined, + lastSeq: response.last_seq || 0, + myLastReadSeq: response.my_last_read_seq || 0, + otherLastReadSeq: response.other_last_read_seq || 0, + unreadCount: response.unread_count || 0, + isPinned: response.is_pinned || false, + participants: response.participants?.map(p => this.mapUserFromApi(p)), + group: response.group ? this.mapGroupFromApi(response.group) : undefined, + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 详情响应转换为应用模型 + */ + static fromDetailApiResponse(response: ConversationDetailResponse): ConversationModel { + return { + id: String(response.id || ''), + type: (response.type as ConversationType) || 'private', + participantId: this.extractParticipantIdFromDetail(response), + lastMessageId: response.last_message?.id, + lastMessage: response.last_message + ? MessageMapper.fromApiResponse(response.last_message) + : undefined, + lastSeq: response.last_seq || 0, + myLastReadSeq: response.my_last_read_seq || 0, + otherLastReadSeq: response.other_last_read_seq || 0, + unreadCount: response.unread_count || 0, + isPinned: response.is_pinned || false, + participants: response.participants?.map(p => this.mapUserFromApi(p)), + group: response.group ? this.mapGroupFromApi(response.group) : undefined, + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: ConversationResponse[]): ConversationModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 从缓存数据转换为应用模型 + */ + static fromCacheData(id: string, cached: ConversationCacheData): ConversationModel { + return { + id: String(cached?.id || id), + type: (cached?.type as ConversationType) || 'private', + participantId: '', + lastSeq: Number(cached?.last_seq || 0), + lastMessage: cached?.last_message + ? MessageMapper.fromApiResponse(cached.last_message) + : undefined, + lastMessageId: cached?.last_message?.id, + myLastReadSeq: Number(cached?.my_last_read_seq || 0), + otherLastReadSeq: Number(cached?.other_last_read_seq || 0), + unreadCount: Number(cached?.unread_count || 0), + isPinned: Boolean(cached?.is_pinned), + participants: Array.isArray(cached?.participants) + ? cached.participants.map(p => this.mapUserFromApi(p)) + : [], + group: cached?.group ? this.mapGroupFromApi(cached.group) : undefined, + createdAt: new Date(cached?.created_at || Date.now()), + updatedAt: new Date(cached?.updated_at || Date.now()), + }; + } + + /** + * 将数据库记录转换为应用模型 + */ + static fromDbRecord(record: ConversationDbRecord): ConversationModel { + return { + id: record.id, + type: 'private', // 数据库中不存储类型,需要额外查询 + participantId: record.participantId, + lastMessageId: record.lastMessageId || undefined, + lastSeq: record.lastSeq, + myLastReadSeq: 0, + otherLastReadSeq: 0, + unreadCount: record.unreadCount, + isPinned: false, + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + }; + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: ConversationModel): ConversationDbRecord { + return { + id: model.id, + participantId: model.participantId, + lastMessageId: model.lastMessageId || null, + lastSeq: model.lastSeq, + unreadCount: model.unreadCount, + createdAt: model.createdAt.toISOString(), + updatedAt: model.updatedAt.toISOString(), + }; + } + + /** + * 将应用模型转换为缓存数据 + */ + static toCacheData(model: ConversationModel): ConversationCacheData { + return { + id: model.id, + type: model.type, + is_pinned: model.isPinned, + last_seq: model.lastSeq, + last_message: model.lastMessage ? this.messageToCache(model.lastMessage) : undefined, + last_message_at: model.updatedAt.toISOString(), + unread_count: model.unreadCount, + participants: model.participants?.map(p => this.userToCache(p)), + my_last_read_seq: model.myLastReadSeq, + other_last_read_seq: model.otherLastReadSeq, + group: model.group ? this.groupToCache(model.group) : undefined, + created_at: model.createdAt.toISOString(), + updated_at: model.updatedAt.toISOString(), + }; + } + + /** + * 提取会话参与者 ID + */ + private static extractParticipantId(response: ConversationResponse): string { + if (response.participants && response.participants.length > 0) { + return String(response.participants[0].id || ''); + } + return ''; + } + + /** + * 从详情响应提取参与者 ID + */ + private static extractParticipantIdFromDetail(response: ConversationDetailResponse): string { + if (response.participants && response.participants.length > 0) { + return String(response.participants[0].id || ''); + } + return ''; + } + + /** + * 映射用户 API 响应 + */ + private static mapUserFromApi(user: UserDTO): UserModel { + return { + id: String(user.id || ''), + username: user.username || '', + nickname: user.nickname, + avatar: user.avatar, + }; + } + + /** + * 映射群组 API 响应 + */ + private static mapGroupFromApi(group: GroupResponse): GroupModel { + return { + id: String(group.id || ''), + name: group.name || '', + avatar: group.avatar, + description: group.description, + announcement: group.announcement, + ownerId: String(group.owner_id || ''), + memberCount: group.member_count || 0, + maxMemberCount: group.max_member_count || 500, + joinType: group.join_type || 'approval', + isMuted: group.mute_all || false, + createdAt: new Date(group.created_at || Date.now()), + updatedAt: new Date(group.updated_at || Date.now()), + }; + } + + /** + * 消息模型转缓存格式 + */ + private static messageToCache(message: MessageModel): any { + return { + id: message.id, + conversation_id: message.conversationId, + sender_id: message.senderId, + content: message.content, + message_type: message.type, + is_read: message.isRead, + created_at: message.createdAt.toISOString(), + seq: message.seq, + status: message.status, + segments: message.segments, + reply_to_id: message.replyToId, + sender: message.sender ? this.userToCache(message.sender) : undefined, + }; + } + + /** + * 用户模型转缓存格式 + */ + private static userToCache(user: UserModel): any { + return { + id: user.id, + username: user.username, + nickname: user.nickname, + avatar: user.avatar, + }; + } + + /** + * 群组模型转缓存格式 + */ + private static groupToCache(group: GroupModel): any { + return { + id: group.id, + name: group.name, + avatar: group.avatar, + description: group.description, + announcement: group.announcement, + owner_id: group.ownerId, + member_count: group.memberCount, + max_member_count: group.maxMemberCount, + join_type: group.joinType, + mute_all: group.isMuted, + created_at: group.createdAt.toISOString(), + updated_at: group.updatedAt.toISOString(), + }; + } +} diff --git a/src/data/mappers/MessageMapper.ts b/src/data/mappers/MessageMapper.ts new file mode 100644 index 0000000..d6e99ea --- /dev/null +++ b/src/data/mappers/MessageMapper.ts @@ -0,0 +1,183 @@ +/** + * 消息数据映射器 + * 负责 Message 模型与 API 响应、数据库记录之间的转换 + */ + +import { + MessageModel, + MessageSegment, + UserModel, +} from '../models'; +import type { + MessageResponse, + UserDTO, +} from '../../types/dto'; + +// 数据库消息记录类型 +export interface MessageDbRecord { + id: string; + conversationId: string; + senderId: string; + content: string | null; + type: string; + isRead: number; + createdAt: string; + seq: number; + status: string; + segments: string | null; +} + +export class MessageMapper { + /** + * 将 API 响应转换为应用模型 + */ + static fromApiResponse(response: MessageResponse): MessageModel { + return { + id: String(response.id || ''), + conversationId: String(response.conversation_id || ''), + senderId: String(response.sender_id || ''), + content: response.content || '', + type: response.message_type || 'text', + isRead: response.is_read || false, + createdAt: new Date(response.created_at || Date.now()), + seq: response.seq || 0, + status: response.status || 'normal', + segments: response.segments || [], + replyToId: response.reply_to_id, + sender: response.sender ? this.mapSenderFromApi(response.sender) : undefined, + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: MessageResponse[]): MessageModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 将数据库记录转换为应用模型 + */ + static fromDbRecord(record: MessageDbRecord): MessageModel { + return { + id: record.id, + conversationId: record.conversationId, + senderId: record.senderId, + content: record.content || undefined, + type: record.type || 'text', + isRead: record.isRead === 1, + createdAt: new Date(record.createdAt), + seq: record.seq || 0, + status: record.status || 'normal', + segments: record.segments ? this.safeParseJson(record.segments) : undefined, + }; + } + + /** + * 将数据库记录数组转换为应用模型数组 + */ + static fromDbRecordList(records: MessageDbRecord[]): MessageModel[] { + return records.map(r => this.fromDbRecord(r)); + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: MessageModel): MessageDbRecord { + return { + id: model.id, + conversationId: model.conversationId, + senderId: model.senderId, + content: model.content || null, + type: model.type, + isRead: model.isRead ? 1 : 0, + createdAt: model.createdAt.toISOString(), + seq: model.seq, + status: model.status, + segments: model.segments ? JSON.stringify(model.segments) : null, + }; + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.segments) { + request.segments = model.segments; + } + if (model.content) { + request.content = model.content; + } + if (model.type) { + request.message_type = model.type; + } + if (model.replyToId) { + request.reply_to_id = model.replyToId; + } + + return request; + } + + /** + * 创建发送消息请求体 + */ + static createSendRequest( + detailType: 'private' | 'group', + segments: MessageSegment[], + replyToId?: string + ): Record { + const body: Record = { + detail_type: detailType, + segments, + }; + if (replyToId) { + body.reply_to_id = replyToId; + } + return body; + } + + /** + * 创建文本消息段 + */ + static createTextSegment(text: string): MessageSegment { + return { + type: 'text', + data: { text }, + }; + } + + /** + * 创建图片消息段 + */ + static createImageSegment(url: string): MessageSegment { + return { + type: 'image', + data: { url }, + }; + } + + /** + * 安全解析 JSON + */ + private static safeParseJson(value: string): T | undefined { + try { + return JSON.parse(value) as T; + } catch { + return undefined; + } + } + + /** + * 映射发送者信息 + */ + private static mapSenderFromApi(sender: UserDTO): UserModel { + return { + id: String(sender.id || ''), + username: sender.username || '', + nickname: sender.nickname, + avatar: sender.avatar, + }; + } +} diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts new file mode 100644 index 0000000..172a6ac --- /dev/null +++ b/src/data/mappers/PostMapper.ts @@ -0,0 +1,123 @@ +/** + * 帖子数据映射器 + * 负责 Post 模型与 API 响应之间的转换 + */ + +import { PostModel, UserModel } from '../models'; +import type { Post } from '../../types'; + +export class PostMapper { + /** + * 将 API 响应转换为应用模型 + */ + static fromApiResponse(response: Post): PostModel { + return { + id: String(response.id || ''), + authorId: String(response.author_id || ''), + author: response.author ? this.mapAuthorFromApi(response.author) : undefined, + title: response.title || '', + content: response.content || '', + images: response.images || [], + likeCount: response.like_count || 0, + commentCount: response.comment_count || 0, + shareCount: response.share_count || 0, + viewCount: response.view_count || 0, + favoriteCount: response.favorite_count || 0, + isLiked: response.is_liked || false, + isFavorited: response.is_favorited || false, + isTop: response.is_top || false, + status: response.status || 'published', + communityId: response.community_id, + tags: response.tags || [], + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: Post[]): PostModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.title !== undefined) { + request.title = model.title; + } + if (model.content !== undefined) { + request.content = model.content; + } + if (model.images !== undefined) { + request.images = model.images; + } + if (model.communityId !== undefined) { + request.community_id = model.communityId; + } + if (model.tags !== undefined) { + request.tags = model.tags; + } + + return request; + } + + /** + * 创建帖子请求 + */ + static createPostRequest( + title: string, + content: string, + images?: string[], + communityId?: string + ): Record { + const request: Record = { + title, + content, + }; + if (images && images.length > 0) { + request.images = images; + } + if (communityId) { + request.community_id = communityId; + } + return request; + } + + /** + * 更新帖子请求 + */ + static updatePostRequest( + title?: string, + content?: string, + images?: string[] + ): Record { + const request: Record = {}; + if (title !== undefined) { + request.title = title; + } + if (content !== undefined) { + request.content = content; + } + if (images !== undefined) { + request.images = images; + } + return request; + } + + /** + * 映射作者信息 + */ + private static mapAuthorFromApi(author: any): UserModel { + return { + id: String(author.id || ''), + username: author.username || '', + nickname: author.nickname, + avatar: author.avatar, + }; + } +} diff --git a/src/data/mappers/UserMapper.ts b/src/data/mappers/UserMapper.ts new file mode 100644 index 0000000..46168a7 --- /dev/null +++ b/src/data/mappers/UserMapper.ts @@ -0,0 +1,168 @@ +/** + * 用户数据映射器 + * 负责 User 模型与 API 响应、数据库记录之间的转换 + */ + +import { UserModel } from '../models'; +import type { UserDTO, User } from '../../types/dto'; + +// 数据库用户记录类型 +export interface UserDbRecord { + id: string; + data: string; + updatedAt: string; +} + +export class UserMapper { + /** + * 将 API DTO 转换为应用模型 + */ + static fromDTO(dto: UserDTO): UserModel { + return { + id: String(dto.id || ''), + username: dto.username || '', + nickname: dto.nickname, + avatar: dto.avatar, + bio: dto.bio, + website: dto.website, + location: dto.location, + email: dto.email, + phone: dto.phone, + followersCount: dto.followers_count, + followingCount: dto.following_count, + postsCount: dto.posts_count, + isFollowing: dto.is_following, + isBlocked: dto.is_blocked, + createdAt: dto.created_at ? new Date(dto.created_at) : undefined, + updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined, + }; + } + + /** + * 将 User 类型转换为应用模型 + */ + static fromUser(user: User): UserModel { + return { + id: String(user.id || ''), + username: user.username || '', + nickname: user.nickname, + avatar: user.avatar, + bio: user.bio, + website: user.website, + location: user.location, + email: user.email, + phone: user.phone, + followersCount: user.followers_count, + followingCount: user.following_count, + postsCount: user.posts_count, + isFollowing: user.is_following, + isBlocked: user.is_blocked, + createdAt: user.created_at ? new Date(user.created_at) : undefined, + updatedAt: user.updated_at ? new Date(user.updated_at) : undefined, + }; + } + + /** + * 将 DTO 数组转换为应用模型数组 + */ + static fromDTOList(dtos: UserDTO[]): UserModel[] { + return dtos.map(dto => this.fromDTO(dto)); + } + + /** + * 从数据库记录转换为应用模型 + */ + static fromDbRecord(record: UserDbRecord): UserModel | null { + try { + const data = JSON.parse(record.data) as UserDTO; + return this.fromDTO(data); + } catch { + return null; + } + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: UserModel): UserDbRecord { + const dto: UserDTO = { + id: model.id, + username: model.username, + nickname: model.nickname, + avatar: model.avatar, + bio: model.bio, + website: model.website, + location: model.location, + email: model.email, + phone: model.phone, + followers_count: model.followersCount, + following_count: model.followingCount, + posts_count: model.postsCount, + is_following: model.isFollowing, + is_blocked: model.isBlocked, + created_at: model.createdAt?.toISOString(), + updated_at: model.updatedAt?.toISOString(), + }; + + return { + id: model.id, + data: JSON.stringify(dto), + updatedAt: new Date().toISOString(), + }; + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.nickname !== undefined) { + request.nickname = model.nickname; + } + if (model.avatar !== undefined) { + request.avatar = model.avatar; + } + if (model.bio !== undefined) { + request.bio = model.bio; + } + if (model.website !== undefined) { + request.website = model.website; + } + if (model.location !== undefined) { + request.location = model.location; + } + if (model.phone !== undefined) { + request.phone = model.phone; + } + if (model.email !== undefined) { + request.email = model.email; + } + + return request; + } + + /** + * 将应用模型转换为 DTO + */ + static toDTO(model: UserModel): UserDTO { + return { + id: model.id, + username: model.username, + nickname: model.nickname, + avatar: model.avatar, + bio: model.bio, + website: model.website, + location: model.location, + email: model.email, + phone: model.phone, + followers_count: model.followersCount, + following_count: model.followingCount, + posts_count: model.postsCount, + is_following: model.isFollowing, + is_blocked: model.isBlocked, + created_at: model.createdAt?.toISOString(), + updated_at: model.updatedAt?.toISOString(), + }; + } +} diff --git a/src/data/mappers/index.ts b/src/data/mappers/index.ts new file mode 100644 index 0000000..291c970 --- /dev/null +++ b/src/data/mappers/index.ts @@ -0,0 +1,9 @@ +/** + * 映射器导出 + * 统一导出所有数据映射器 + */ + +export * from './MessageMapper'; +export * from './ConversationMapper'; +export * from './UserMapper'; +export * from './PostMapper'; diff --git a/src/data/models/index.ts b/src/data/models/index.ts new file mode 100644 index 0000000..cfbf362 --- /dev/null +++ b/src/data/models/index.ts @@ -0,0 +1,153 @@ +/** + * Repository 层模型定义 + * 定义应用内部使用的数据模型,与 API 响应和数据库结构解耦 + */ + +// ==================== 消息模型 ==================== + +export interface MessageSegment { + type: string; + data: Record; +} + +export interface MessageModel { + id: string; + conversationId: string; + senderId: string; + content?: string; + type: 'text' | 'image' | 'file' | 'system' | string; + isRead: boolean; + createdAt: Date; + seq: number; + status: 'normal' | 'recalled' | 'deleted' | string; + segments?: MessageSegment[]; + replyToId?: string; + sender?: UserModel; +} + +// ==================== 会话模型 ==================== + +export type ConversationType = 'private' | 'group'; + +export interface ConversationModel { + id: string; + type: ConversationType; + participantId: string; + lastMessageId?: string; + lastMessage?: MessageModel; + lastSeq: number; + myLastReadSeq: number; + otherLastReadSeq: number; + unreadCount: number; + isPinned: boolean; + participants?: UserModel[]; + group?: GroupModel; + createdAt: Date; + updatedAt: Date; +} + +// ==================== 用户模型 ==================== + +export interface UserModel { + id: string; + username: string; + nickname?: string; + avatar?: string; + bio?: string; + website?: string; + location?: string; + email?: string; + phone?: string; + followersCount?: number; + followingCount?: number; + postsCount?: number; + isFollowing?: boolean; + isBlocked?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +// ==================== 帖子模型 ==================== + +export interface PostModel { + id: string; + authorId: string; + author?: UserModel; + title: string; + content: string; + images?: string[]; + likeCount: number; + commentCount: number; + shareCount: number; + viewCount: number; + favoriteCount: number; + isLiked: boolean; + isFavorited: boolean; + isTop: boolean; + status: 'published' | 'draft' | 'deleted'; + communityId?: string; + tags?: string[]; + createdAt: Date; + updatedAt: Date; +} + +// ==================== 群组模型 ==================== + +export type GroupMemberRole = 'owner' | 'admin' | 'member'; +export type GroupJoinType = 'anyone' | 'approval' | 'invite'; + +export interface GroupMemberModel { + userId: string; + user?: UserModel; + role: GroupMemberRole; + nickname?: string; + joinTime: Date; + muteUntil?: Date; + isMuted?: boolean; +} + +export interface GroupModel { + id: string; + name: string; + avatar?: string; + description?: string; + announcement?: string; + ownerId: string; + owner?: UserModel; + memberCount: number; + maxMemberCount: number; + joinType: GroupJoinType; + isMuted: boolean; + createdAt: Date; + updatedAt: Date; +} + +// ==================== 分页模型 ==================== + +export interface PaginatedResult { + list: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + hasMore: boolean; +} + +// ==================== 通用操作结果 ==================== + +export interface OperationResult { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +// ==================== 同步状态模型 ==================== + +export interface SyncStatus { + entityType: string; + entityId: string; + lastSyncedAt: Date; + syncVersion: number; + pendingChanges: boolean; +} diff --git a/src/data/repositories/MessageRepository.ts b/src/data/repositories/MessageRepository.ts new file mode 100644 index 0000000..5ad28a2 --- /dev/null +++ b/src/data/repositories/MessageRepository.ts @@ -0,0 +1,220 @@ +/** + * MessageRepository - 消息仓库实现 + * 封装所有SQLite数据库操作,不依赖任何UI或状态管理 + */ + +import { + saveMessage as dbSaveMessage, + saveMessagesBatch as dbSaveMessagesBatch, + getMessagesByConversation as dbGetMessagesByConversation, + getMaxSeq as dbGetMaxSeq, + getMinSeq as dbGetMinSeq, + getMessagesBeforeSeq as dbGetMessagesBeforeSeq, + markConversationAsRead as dbMarkConversationAsRead, + updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount, + getUserCache as dbGetUserCache, + saveUserCache as dbSaveUserCache, + deleteConversation as dbDeleteConversation, + updateMessageStatus as dbUpdateMessageStatus, + CachedMessage, +} from '../../services/database'; +import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message'; + +// 数据库消息到领域实体的转换 +const cachedMessageToMessage = (cached: CachedMessage): Message => ({ + id: cached.id, + conversationId: cached.conversationId, + senderId: cached.senderId, + seq: cached.seq, + segments: cached.segments || [], + createdAt: cached.createdAt, + status: cached.status as Message['status'], +}); + +// 领域实体到数据库消息的转换 +const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({ + id: message.id, + conversationId: message.conversationId, + senderId: message.senderId, + content: message.segments + .filter((s) => s.type === 'text') + .map((s) => s.data?.text || '') + .join(''), + type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text', + isRead, + createdAt: message.createdAt, + seq: message.seq, + status: message.status, + segments: message.segments, +}); + +class MessageRepository { + // ==================== 消息操作 ==================== + + /** + * 保存单条消息 + */ + async saveMessage(message: Message, isRead: boolean): Promise { + try { + const cachedMessage = messageToCachedMessage(message, isRead); + await dbSaveMessage(cachedMessage); + } catch (error) { + console.error('[MessageRepository] 保存消息失败:', error); + throw error; + } + } + + /** + * 批量保存消息 + */ + async saveMessages(messages: Message[], isRead: boolean): Promise { + if (!messages || messages.length === 0) return; + + try { + const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead)); + await dbSaveMessagesBatch(cachedMessages); + } catch (error) { + console.error('[MessageRepository] 批量保存消息失败:', error); + throw error; + } + } + + /** + * 获取会话的消息 + */ + async getMessagesByConversation( + conversationId: string, + limit: number = 20 + ): Promise { + try { + const cachedMessages = await dbGetMessagesByConversation(conversationId, limit); + return cachedMessages.map(cachedMessageToMessage); + } catch (error) { + console.error('[MessageRepository] 获取消息失败:', error); + return []; + } + } + + /** + * 获取会话的最大消息序号 + */ + async getMaxSeq(conversationId: string): Promise { + try { + return await dbGetMaxSeq(conversationId); + } catch (error) { + console.error('[MessageRepository] 获取最大序号失败:', error); + return 0; + } + } + + /** + * 获取会话的最小消息序号 + */ + async getMinSeq(conversationId: string): Promise { + try { + return await dbGetMinSeq(conversationId); + } catch (error) { + console.error('[MessageRepository] 获取最小序号失败:', error); + return 0; + } + } + + /** + * 获取指定seq之前的历史消息 + */ + async getMessagesBeforeSeq( + conversationId: string, + beforeSeq: number, + limit: number = 20 + ): Promise { + try { + const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit); + return cachedMessages.map(cachedMessageToMessage); + } catch (error) { + console.error('[MessageRepository] 获取历史消息失败:', error); + return []; + } + } + + /** + * 标记会话的所有消息为已读 + */ + async markConversationAsRead(conversationId: string): Promise { + try { + await dbMarkConversationAsRead(conversationId); + } catch (error) { + console.error('[MessageRepository] 标记会话已读失败:', error); + throw error; + } + } + + /** + * 更新会话缓存的未读数 + */ + async updateConversationUnreadCount( + conversationId: string, + count: number + ): Promise { + try { + await dbUpdateConversationCacheUnreadCount(conversationId, count); + } catch (error) { + console.error('[MessageRepository] 更新会话未读数失败:', error); + } + } + + /** + * 更新消息状态(如撤回) + */ + async updateMessageStatus( + messageId: string, + status: string, + clearContent: boolean = false + ): Promise { + try { + await dbUpdateMessageStatus(messageId, status, clearContent); + } catch (error) { + console.error('[MessageRepository] 更新消息状态失败:', error); + throw error; + } + } + + /** + * 删除会话及其所有消息 + */ + async deleteConversation(conversationId: string): Promise { + try { + await dbDeleteConversation(conversationId); + } catch (error) { + console.error('[MessageRepository] 删除会话失败:', error); + throw error; + } + } + + // ==================== 用户缓存操作 ==================== + + /** + * 获取用户缓存 + */ + async getUserCache(userId: string): Promise { + try { + return await dbGetUserCache(userId); + } catch (error) { + console.error('[MessageRepository] 获取用户缓存失败:', error); + return null; + } + } + + /** + * 保存用户缓存 + */ + async saveUserCache(user: any): Promise { + try { + await dbSaveUserCache(user); + } catch (error) { + console.error('[MessageRepository] 保存用户缓存失败:', error); + } + } +} + +export const messageRepository = new MessageRepository(); +export default messageRepository; diff --git a/src/data/repositories/interfaces/IMessageRepository.ts b/src/data/repositories/interfaces/IMessageRepository.ts new file mode 100644 index 0000000..1964401 --- /dev/null +++ b/src/data/repositories/interfaces/IMessageRepository.ts @@ -0,0 +1,93 @@ +/** + * 消息 Repository 接口 + * 定义消息数据访问的抽象 + */ + +import type { Message, Conversation, ConversationType } from '../../types/dto'; + +export interface IMessageRepository { + // ==================== 消息操作 ==================== + + /** + * 获取会话的消息列表 + */ + getMessages( + conversationId: string, + options?: { + beforeSeq?: number; + afterSeq?: number; + limit?: number; + } + ): Promise; + + /** + * 保存消息 + */ + saveMessage(message: Message): Promise; + + /** + * 批量保存消息 + */ + saveMessages(messages: Message[]): Promise; + + /** + * 更新消息状态 + */ + updateMessageStatus( + messageId: string, + status: 'normal' | 'recalled' | 'deleted' + ): Promise; + + /** + * 标记消息为已读 + */ + markAsRead(messageIds: string[]): Promise; + + /** + * 获取未读消息数 + */ + getUnreadCount(conversationId: string): Promise; + + // ==================== 会话操作 ==================== + + /** + * 获取会话列表 + */ + getConversations(): Promise; + + /** + * 获取单个会话 + */ + getConversation(conversationId: string): Promise; + + /** + * 保存会话 + */ + saveConversation(conversation: Conversation): Promise; + + /** + * 更新会话未读数 + */ + updateUnreadCount(conversationId: string, count: number): Promise; + + /** + * 更新会话最后消息 + */ + updateLastMessage( + conversationId: string, + messageId: string, + seq: number + ): Promise; + + // ==================== 同步操作 ==================== + + /** + * 从服务器同步消息 + */ + syncMessages(conversationId: string, lastSeq: number): Promise; + + /** + * 获取服务器最新消息序列号 + */ + getServerLastSeq(conversationId: string): Promise; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 94d29f1..4894bdb 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,31 +2,66 @@ * Hooks 导出 */ -// 响应式相关 Hooks +// ==================== 新的响应式 Hooks (推荐) ==================== export { - useResponsive, - BREAKPOINTS, - FINE_BREAKPOINTS, - useResponsiveValue, - useResponsiveStyle, + // 核心 hooks + useBreakpoint, + useFineBreakpoint, useBreakpointGTE, useBreakpointLT, useBreakpointBetween, + useScreenSize, + useWindowDimensions, + useOrientation, usePlatform, useMediaQuery, + useResponsiveValue, + useResponsiveStyle, useColumnCount, useResponsiveSpacing, + // 兼容层 + useResponsive, + useLegacyResponsive, + // 工具函数 + getBreakpoint, + getFineBreakpoint, isBreakpointGTE, isBreakpointLT, -} from './useResponsive'; + isBreakpointBetween, + // 常量 + BREAKPOINTS, + FINE_BREAKPOINTS, +} from '../presentation/hooks/responsive'; + export type { - ResponsiveInfo, BreakpointKey, - BreakpointValue, FineBreakpointKey, + BreakpointValue, ResponsiveValue, + Orientation, + PlatformInfo, + ScreenSize, + MediaQueryOptions, + // 兼容层类型 + ResponsiveInfo, +} from '../presentation/hooks/responsive'; + +// ==================== 旧版响应式 Hooks (保持向后兼容) ==================== +// 注意:这些导出将在未来版本中移除,请使用新的 hooks +export { + BREAKPOINTS as BREAKPOINTS_OLD, + FINE_BREAKPOINTS as FINE_BREAKPOINTS_OLD, } from './useResponsive'; +export type { + ResponsiveInfo as ResponsiveInfo_OLD, + BreakpointKey as BreakpointKey_OLD, + BreakpointValue as BreakpointValue_OLD, + FineBreakpointKey as FineBreakpointKey_OLD, + ResponsiveValue as ResponsiveValue_OLD, +} from './useResponsive'; + +// ==================== 其他 Hooks ==================== export { usePrefetch, prefetchPosts, diff --git a/src/hooks/responsive/constants.ts b/src/hooks/responsive/constants.ts new file mode 100644 index 0000000..ae57078 --- /dev/null +++ b/src/hooks/responsive/constants.ts @@ -0,0 +1,82 @@ +/** + * 响应式常量定义 + */ + +// ==================== 断点定义 ==================== +export const BREAKPOINTS = { + mobile: 0, // 手机 + tablet: 768, // 平板竖屏 + desktop: 1024, // 平板横屏/桌面 + wide: 1440, // 宽屏桌面 +} as const; + +export type BreakpointKey = keyof typeof BREAKPOINTS; +export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey]; + +// ==================== 更细粒度的断点定义 ==================== +export const FINE_BREAKPOINTS = { + xs: 0, // 超小屏手机 + sm: 375, // 小屏手机 (iPhone SE, 小屏安卓) + md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓) + lg: 768, // 平板竖屏 / 大折叠屏手机展开 + xl: 1024, // 平板横屏 / 小桌面 + '2xl': 1280, // 桌面 + '3xl': 1440, // 大桌面 + '4xl': 1920, // 超大屏 +} as const; + +export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS; + +// ==================== 响应式值类型 ==================== +export type ResponsiveValue = T | Partial>; + +// ==================== 断点顺序(用于比较)==================== +export const BREAKPOINT_ORDER: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl']; +export const REVERSE_BREAKPOINT_ORDER: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + +// ==================== 获取当前断点 ==================== +export function getBreakpoint(width: number): BreakpointKey { + if (width >= BREAKPOINTS.wide) { + return 'wide'; + } + if (width >= BREAKPOINTS.desktop) { + return 'desktop'; + } + if (width >= BREAKPOINTS.tablet) { + return 'tablet'; + } + return 'mobile'; +} + +// ==================== 获取细粒度断点 ==================== +export function getFineBreakpoint(width: number): FineBreakpointKey { + if (width >= FINE_BREAKPOINTS['4xl']) return '4xl'; + if (width >= FINE_BREAKPOINTS['3xl']) return '3xl'; + if (width >= FINE_BREAKPOINTS['2xl']) return '2xl'; + if (width >= FINE_BREAKPOINTS.xl) return 'xl'; + if (width >= FINE_BREAKPOINTS.lg) return 'lg'; + if (width >= FINE_BREAKPOINTS.md) return 'md'; + if (width >= FINE_BREAKPOINTS.sm) return 'sm'; + return 'xs'; +} + +// ==================== 断点比较工具 ==================== +/** + * 检查当前断点是否大于等于目标断点 + */ +export function isBreakpointGTE( + current: FineBreakpointKey, + target: FineBreakpointKey +): boolean { + return BREAKPOINT_ORDER.indexOf(current) >= BREAKPOINT_ORDER.indexOf(target); +} + +/** + * 检查当前断点是否小于目标断点 + */ +export function isBreakpointLT( + current: FineBreakpointKey, + target: FineBreakpointKey +): boolean { + return !isBreakpointGTE(current, target); +} diff --git a/src/hooks/responsive/index.ts b/src/hooks/responsive/index.ts new file mode 100644 index 0000000..022fb89 --- /dev/null +++ b/src/hooks/responsive/index.ts @@ -0,0 +1,45 @@ +/** + * 响应式 Hooks 统一导出 + */ + +// 基础 Hooks +export { useWindowDimensions } from './useWindowDimensions'; +export { useBreakpoint, type BreakpointInfo } from './useBreakpoint'; +export { useOrientation, type Orientation, type OrientationInfo } from './useOrientation'; +export { usePlatform, type PlatformInfo } from './usePlatform'; +export { useScreenSize, type ScreenSizeInfo } from './useScreenSize'; + +// 响应式值 Hooks +export { useResponsiveValue } from './useResponsiveValue'; +export { useResponsiveStyle } from './useResponsiveStyle'; +export { useResponsiveSpacing } from './useResponsiveSpacing'; + +// 断点范围 Hooks +export { + useBreakpointGTE, + useBreakpointLT, + useBreakpointBetween, +} from './useBreakpointRange'; + +// 工具 Hooks +export { useMediaQuery } from './useMediaQuery'; +export { useColumnCount } from './useColumnCount'; + +// 常量和工具函数 +export { + BREAKPOINTS, + FINE_BREAKPOINTS, + BREAKPOINT_ORDER, + REVERSE_BREAKPOINT_ORDER, + type BreakpointKey, + type BreakpointValue, + type FineBreakpointKey, + type ResponsiveValue, + getBreakpoint, + getFineBreakpoint, + isBreakpointGTE, + isBreakpointLT, +} from './constants'; + +// 为了保持向后兼容,重新导出 useResponsive +export { useResponsive, type ResponsiveInfo } from './useResponsive'; diff --git a/src/hooks/responsive/useBreakpoint.ts b/src/hooks/responsive/useBreakpoint.ts new file mode 100644 index 0000000..f05e948 --- /dev/null +++ b/src/hooks/responsive/useBreakpoint.ts @@ -0,0 +1,62 @@ +/** + * 断点检测 Hook + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; +import { + BreakpointKey, + FineBreakpointKey, + getBreakpoint, + getFineBreakpoint, +} from './constants'; + +export interface BreakpointInfo { + // 基础断点 + breakpoint: BreakpointKey; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; + isWideScreen: boolean; + + // 细粒度断点 + fineBreakpoint: FineBreakpointKey; + isXS: boolean; + isSM: boolean; + isMD: boolean; + isLG: boolean; + isXL: boolean; + is2XL: boolean; + is3XL: boolean; + is4XL: boolean; +} + +/** + * 断点检测 Hook + * 返回当前断点信息和布尔标志 + */ +export function useBreakpoint(): BreakpointInfo { + const { width } = useWindowDimensions(); + + const breakpoint = useMemo(() => getBreakpoint(width), [width]); + const fineBreakpoint = useMemo(() => getFineBreakpoint(width), [width]); + + return useMemo(() => ({ + breakpoint, + isMobile: breakpoint === 'mobile', + isTablet: breakpoint === 'tablet', + isDesktop: breakpoint === 'desktop', + isWide: breakpoint === 'wide', + isWideScreen: breakpoint === 'tablet' || breakpoint === 'desktop' || breakpoint === 'wide', + fineBreakpoint, + isXS: fineBreakpoint === 'xs', + isSM: fineBreakpoint === 'sm', + isMD: fineBreakpoint === 'md', + isLG: fineBreakpoint === 'lg', + isXL: fineBreakpoint === 'xl', + is2XL: fineBreakpoint === '2xl', + is3XL: fineBreakpoint === '3xl', + is4XL: fineBreakpoint === '4xl', + }), [breakpoint, fineBreakpoint]); +} diff --git a/src/hooks/responsive/useBreakpointRange.ts b/src/hooks/responsive/useBreakpointRange.ts new file mode 100644 index 0000000..232f7cd --- /dev/null +++ b/src/hooks/responsive/useBreakpointRange.ts @@ -0,0 +1,46 @@ +/** + * 断点范围检查 Hooks + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { FineBreakpointKey, isBreakpointGTE, isBreakpointLT } from './constants'; + +/** + * 检查当前断点是否大于等于目标断点 + * + * @example + * const isMediumUp = useBreakpointGTE('md'); + */ +export function useBreakpointGTE(target: FineBreakpointKey): boolean { + const { fineBreakpoint } = useBreakpoint(); + return useMemo(() => isBreakpointGTE(fineBreakpoint, target), [fineBreakpoint, target]); +} + +/** + * 检查当前断点是否小于目标断点 + * + * @example + * const isMobileOnly = useBreakpointLT('lg'); + */ +export function useBreakpointLT(target: FineBreakpointKey): boolean { + const { fineBreakpoint } = useBreakpoint(); + return useMemo(() => isBreakpointLT(fineBreakpoint, target), [fineBreakpoint, target]); +} + +/** + * 检查当前是否在指定断点范围内 + * + * @example + * const isTabletRange = useBreakpointBetween('md', 'xl'); + */ +export function useBreakpointBetween( + min: FineBreakpointKey, + max: FineBreakpointKey +): boolean { + const { fineBreakpoint } = useBreakpoint(); + return useMemo( + () => isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max), + [fineBreakpoint, min, max] + ); +} diff --git a/src/hooks/responsive/useColumnCount.ts b/src/hooks/responsive/useColumnCount.ts new file mode 100644 index 0000000..bc61348 --- /dev/null +++ b/src/hooks/responsive/useColumnCount.ts @@ -0,0 +1,53 @@ +/** + * 列数计算 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { FineBreakpointKey } from './constants'; + +const DEFAULT_COLUMN_CONFIG: Record = { + xs: 1, + sm: 1, + md: 2, + lg: 3, + xl: 4, + '2xl': 4, + '3xl': 5, + '4xl': 6, +}; + +/** + * 根据容器宽度计算合适的列数 + * + * @param columnConfig - 列数配置 + * @returns 列数 + * + * @example + * const columns = useColumnCount({ + * xs: 1, + * sm: 2, + * md: 3, + * lg: 4 + * }); + */ +export function useColumnCount( + columnConfig: Partial> = {} +): number { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + const config = { ...DEFAULT_COLUMN_CONFIG, ...columnConfig }; + const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (config[bp] !== undefined) { + return config[bp]; + } + } + + return 1; + }, [fineBreakpoint, columnConfig]); +} diff --git a/src/hooks/responsive/useMediaQuery.ts b/src/hooks/responsive/useMediaQuery.ts new file mode 100644 index 0000000..7f28a13 --- /dev/null +++ b/src/hooks/responsive/useMediaQuery.ts @@ -0,0 +1,39 @@ +/** + * 媒体查询模拟 Hook + */ + +import { useMemo } from 'react'; +import { useScreenSize } from './useScreenSize'; +import { useOrientation } from './useOrientation'; + +interface MediaQueryOptions { + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + orientation?: 'portrait' | 'landscape'; +} + +/** + * 模拟 CSS 媒体查询 + * + * @param query - 查询条件 + * @returns 是否匹配 + * + * @example + * const isMinWidth768 = useMediaQuery({ minWidth: 768 }); + * const isPortrait = useMediaQuery({ orientation: 'portrait' }); + */ +export function useMediaQuery(query: MediaQueryOptions): boolean { + const { width, height } = useScreenSize(); + const { orientation: currentOrientation } = useOrientation(); + + return useMemo(() => { + if (query.minWidth !== undefined && width < query.minWidth) return false; + if (query.maxWidth !== undefined && width > query.maxWidth) return false; + if (query.minHeight !== undefined && height < query.minHeight) return false; + if (query.maxHeight !== undefined && height > query.maxHeight) return false; + if (query.orientation !== undefined && currentOrientation !== query.orientation) return false; + return true; + }, [width, height, currentOrientation, query]); +} diff --git a/src/hooks/responsive/useOrientation.ts b/src/hooks/responsive/useOrientation.ts new file mode 100644 index 0000000..07738dd --- /dev/null +++ b/src/hooks/responsive/useOrientation.ts @@ -0,0 +1,36 @@ +/** + * 屏幕方向检测 Hook + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; + +export type Orientation = 'portrait' | 'landscape'; + +export interface OrientationInfo { + orientation: Orientation; + isPortrait: boolean; + isLandscape: boolean; +} + +/** + * 获取屏幕方向 + */ +function getOrientation(width: number, height: number): Orientation { + return width > height ? 'landscape' : 'portrait'; +} + +/** + * 屏幕方向检测 Hook + */ +export function useOrientation(): OrientationInfo { + const { width, height } = useWindowDimensions(); + + const orientation = useMemo(() => getOrientation(width, height), [width, height]); + + return useMemo(() => ({ + orientation, + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + }), [orientation]); +} diff --git a/src/hooks/responsive/usePlatform.ts b/src/hooks/responsive/usePlatform.ts new file mode 100644 index 0000000..269050a --- /dev/null +++ b/src/hooks/responsive/usePlatform.ts @@ -0,0 +1,31 @@ +/** + * 平台检测 Hook + */ + +import { useMemo } from 'react'; +import { Platform } from 'react-native'; + +export interface PlatformInfo { + OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; + isWeb: boolean; + isIOS: boolean; + isAndroid: boolean; + isNative: boolean; +} + +/** + * 平台检测 Hook + * 提供便捷的平台检测方法 + * + * @example + * const { isWeb, isIOS, isAndroid, isNative } = usePlatform(); + */ +export function usePlatform(): PlatformInfo { + return useMemo(() => ({ + OS: Platform.OS, + isWeb: Platform.OS === 'web', + isIOS: Platform.OS === 'ios', + isAndroid: Platform.OS === 'android', + isNative: Platform.OS !== 'web', + }), []); +} diff --git a/src/hooks/responsive/useResponsive.ts b/src/hooks/responsive/useResponsive.ts new file mode 100644 index 0000000..c5f1078 --- /dev/null +++ b/src/hooks/responsive/useResponsive.ts @@ -0,0 +1,103 @@ +/** + * 响应式设计 Hook(重构版) + * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 + * + * 注意:此 Hook 现在作为各个专注 Hook 的组合,保持向后兼容 + * 新项目建议直接使用专注的 Hooks + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; +import { useBreakpoint } from './useBreakpoint'; +import { useOrientation } from './useOrientation'; +import { usePlatform } from './usePlatform'; + +// ==================== 返回值类型 ==================== +export interface ResponsiveInfo { + // 基础尺寸 + width: number; + height: number; + + // 基础断点 + breakpoint: import('./constants').BreakpointKey; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; + isWideScreen: boolean; + + // 细粒度断点 + fineBreakpoint: import('./constants').FineBreakpointKey; + isXS: boolean; + isSM: boolean; + isMD: boolean; + isLG: boolean; + isXL: boolean; + is2XL: boolean; + is3XL: boolean; + is4XL: boolean; + + // 方向 + orientation: 'portrait' | 'landscape'; + isPortrait: boolean; + isLandscape: boolean; + + // 平台检测 + platform: { + OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; + isWeb: boolean; + isIOS: boolean; + isAndroid: boolean; + isNative: boolean; + }; +} + +/** + * 响应式设计 Hook + * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 + * + * @returns ResponsiveInfo 响应式信息对象 + * + * @example + * const { + * width, height, + * breakpoint, isMobile, isTablet, isDesktop, isWide, + * fineBreakpoint, isXS, isSM, isMD, isLG, isXL, + * orientation, isPortrait, isLandscape, + * platform: { isWeb, isIOS, isAndroid } + * } = useResponsive(); + * + * @deprecated 建议使用专注的 Hooks:useBreakpoint, useOrientation, usePlatform, useScreenSize + */ +export function useResponsive(): ResponsiveInfo { + const { width, height } = useWindowDimensions(); + const breakpointInfo = useBreakpoint(); + const orientationInfo = useOrientation(); + const platformInfo = usePlatform(); + + return useMemo(() => ({ + width, + height, + breakpoint: breakpointInfo.breakpoint, + isMobile: breakpointInfo.isMobile, + isTablet: breakpointInfo.isTablet, + isDesktop: breakpointInfo.isDesktop, + isWide: breakpointInfo.isWide, + isWideScreen: breakpointInfo.isWideScreen, + fineBreakpoint: breakpointInfo.fineBreakpoint, + isXS: breakpointInfo.isXS, + isSM: breakpointInfo.isSM, + isMD: breakpointInfo.isMD, + isLG: breakpointInfo.isLG, + isXL: breakpointInfo.isXL, + is2XL: breakpointInfo.is2XL, + is3XL: breakpointInfo.is3XL, + is4XL: breakpointInfo.is4XL, + orientation: orientationInfo.orientation, + isPortrait: orientationInfo.isPortrait, + isLandscape: orientationInfo.isLandscape, + platform: platformInfo, + }), [width, height, breakpointInfo, orientationInfo, platformInfo]); +} + +export default useResponsive; diff --git a/src/hooks/responsive/useResponsiveSpacing.ts b/src/hooks/responsive/useResponsiveSpacing.ts new file mode 100644 index 0000000..6175d05 --- /dev/null +++ b/src/hooks/responsive/useResponsiveSpacing.ts @@ -0,0 +1,48 @@ +/** + * 响应式间距 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { FineBreakpointKey } from './constants'; + +const DEFAULT_SPACING_CONFIG: Record = { + xs: 8, + sm: 8, + md: 12, + lg: 16, + xl: 20, + '2xl': 24, + '3xl': 32, + '4xl': 40, +}; + +/** + * 响应式间距 Hook + * + * @param spacingConfig - 间距配置 + * @returns 当前断点对应的间距值 + * + * @example + * const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); + */ +export function useResponsiveSpacing( + spacingConfig: Partial> = {} +): number { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + const config = { ...DEFAULT_SPACING_CONFIG, ...spacingConfig }; + const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (config[bp] !== undefined) { + return config[bp]; + } + } + + return DEFAULT_SPACING_CONFIG.xs; + }, [fineBreakpoint, spacingConfig]); +} diff --git a/src/hooks/responsive/useResponsiveStyle.ts b/src/hooks/responsive/useResponsiveStyle.ts new file mode 100644 index 0000000..6f03124 --- /dev/null +++ b/src/hooks/responsive/useResponsiveStyle.ts @@ -0,0 +1,53 @@ +/** + * 响应式样式生成器 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants'; + +/** + * 根据断点生成响应式样式 + * + * @param styles - 响应式样式对象 + * @returns 当前断点对应的样式 + * + * @example + * const containerStyle = useResponsiveStyle({ + * padding: { xs: 8, md: 16, lg: 24 }, + * fontSize: { xs: 14, lg: 16 } + * }); + */ +export function useResponsiveStyle>>( + styles: T +): { [K in keyof T]: T[K] extends ResponsiveValue ? V : never } { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue ? V : never }; + + for (const key in styles) { + const value = styles[key]; + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + (result as Record)[key] = value; + } else { + const valueMap = value as Partial>; + const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint); + + let selectedValue: unknown = undefined; + for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) { + const bp = REVERSE_BREAKPOINT_ORDER[i]; + if (bp in valueMap) { + selectedValue = valueMap[bp]; + break; + } + } + + (result as Record)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0]; + } + } + + return result; + }, [styles, fineBreakpoint]); +} diff --git a/src/hooks/responsive/useResponsiveValue.ts b/src/hooks/responsive/useResponsiveValue.ts new file mode 100644 index 0000000..76d47da --- /dev/null +++ b/src/hooks/responsive/useResponsiveValue.ts @@ -0,0 +1,42 @@ +/** + * 响应式值选择器 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants'; + +/** + * 根据当前断点从响应式值对象中选择合适的值 + * + * @param value - 响应式值,可以是单一值或断点映射对象 + * @returns 选中的值 + * + * @example + * const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); + * // 在 xs 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 24 + */ +export function useResponsiveValue(value: ResponsiveValue): T { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + // 如果不是对象,直接返回 + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value as T; + } + + const valueMap = value as Partial>; + + // 从当前断点开始向下查找 + const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint); + for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) { + const bp = REVERSE_BREAKPOINT_ORDER[i]; + if (bp in valueMap) { + return valueMap[bp]!; + } + } + + // 如果没找到,返回 xs 的值或第一个值 + return (valueMap.xs ?? Object.values(valueMap)[0]) as T; + }, [value, fineBreakpoint]); +} diff --git a/src/hooks/responsive/useScreenSize.ts b/src/hooks/responsive/useScreenSize.ts new file mode 100644 index 0000000..0dc983c --- /dev/null +++ b/src/hooks/responsive/useScreenSize.ts @@ -0,0 +1,34 @@ +/** + * 屏幕尺寸 Hook + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; +import { useBreakpoint } from './useBreakpoint'; + +export interface ScreenSizeInfo { + width: number; + height: number; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; +} + +/** + * 屏幕尺寸 Hook + * 返回屏幕尺寸和断点信息 + */ +export function useScreenSize(): ScreenSizeInfo { + const { width, height } = useWindowDimensions(); + const { isMobile, isTablet, isDesktop, isWide } = useBreakpoint(); + + return useMemo(() => ({ + width, + height, + isMobile, + isTablet, + isDesktop, + isWide, + }), [width, height, isMobile, isTablet, isDesktop, isWide]); +} diff --git a/src/hooks/responsive/useWindowDimensions.ts b/src/hooks/responsive/useWindowDimensions.ts new file mode 100644 index 0000000..00f36c6 --- /dev/null +++ b/src/hooks/responsive/useWindowDimensions.ts @@ -0,0 +1,27 @@ +/** + * 窗口尺寸 Hook + * 提供屏幕尺寸监听 + */ + +import { useState, useEffect } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; + +/** + * 获取窗口尺寸,支持屏幕旋转响应 + * 替代 Dimensions.get('window'),提供实时尺寸更新 + */ +export function useWindowDimensions(): ScaledSize { + const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setDimensions(window); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return dimensions; +} diff --git a/src/infrastructure/navigation/hooks/useNavigationState.ts b/src/infrastructure/navigation/hooks/useNavigationState.ts new file mode 100644 index 0000000..2792e37 --- /dev/null +++ b/src/infrastructure/navigation/hooks/useNavigationState.ts @@ -0,0 +1,60 @@ +/** + * 导航状态管理 Hook + * 管理导航相关状态,如当前路由、导航历史等 + */ + +import { useState, useCallback, useEffect } from 'react'; +import { useNavigationState as useRNNavigationState, Route } from '@react-navigation/native'; +import type { RootStackParamList } from '../../../navigation/types'; + +type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; + +interface NavigationState { + currentTab: TabName; + isCollapsed: boolean; + isReady: boolean; +} + +interface NavigationActions { + setCurrentTab: (tab: TabName) => void; + toggleCollapse: () => void; + setIsReady: (ready: boolean) => void; +} + +/** + * 导航状态管理 Hook + */ +export function useNavigationState(): NavigationState & NavigationActions { + const [currentTab, setCurrentTab] = useState('HomeTab'); + const [isCollapsed, setIsCollapsed] = useState(false); + const [isReady, setIsReady] = useState(false); + + const toggleCollapse = useCallback(() => { + setIsCollapsed(prev => !prev); + }, []); + + return { + currentTab, + isCollapsed, + isReady, + setCurrentTab, + toggleCollapse, + setIsReady, + }; +} + +/** + * 获取当前路由名称 + */ +export function useCurrentRouteName(): string | null { + const routeName = useRNNavigationState(state => state?.routes[state.index]?.name ?? null); + return routeName; +} + +/** + * 检查是否在指定路由 + */ +export function useIsRoute(routeName: keyof RootStackParamList): boolean { + const currentRoute = useCurrentRouteName(); + return currentRoute === routeName; +} diff --git a/src/infrastructure/navigation/navigationService.ts b/src/infrastructure/navigation/navigationService.ts new file mode 100644 index 0000000..f9ca8fa --- /dev/null +++ b/src/infrastructure/navigation/navigationService.ts @@ -0,0 +1,110 @@ +/** + * 导航服务层 + * 提供全局导航功能,解耦组件与导航状态的直接依赖 + */ + +import { NavigationContainerRef, Route } from '@react-navigation/native'; + +class NavigationService { + private navigationRef: NavigationContainerRef | null = null; + private isReady: boolean = false; + + /** + * 设置导航引用 + */ + setNavigationRef(ref: NavigationContainerRef | null): void { + this.navigationRef = ref; + this.isReady = ref !== null; + } + + /** + * 检查导航是否就绪 + */ + isNavigationReady(): boolean { + return this.isReady && this.navigationRef !== null; + } + + /** + * 导航到指定路由 + */ + navigate(routeName: string, params?: any): void { + if (!this.isNavigationReady()) { + console.warn('[NavigationService] Navigation not ready'); + return; + } + this.navigationRef!.navigate(routeName as any, params); + } + + /** + * 返回上一页 + */ + goBack(): void { + if (!this.isNavigationReady()) { + console.warn('[NavigationService] Navigation not ready'); + return; + } + this.navigationRef!.goBack(); + } + + /** + * 获取当前路由 + */ + getCurrentRoute(): Route | null { + if (!this.isNavigationReady()) { + return null; + } + return this.navigationRef!.getCurrentRoute(); + } + + /** + * 获取当前路由名称 + */ + getCurrentRouteName(): string | null { + const route = this.getCurrentRoute(); + return route?.name ?? null; + } + + /** + * 重置导航到指定路由 + */ + reset(routes: { name: string; params?: any }[], index: number = 0): void { + if (!this.isNavigationReady()) { + console.warn('[NavigationService] Navigation not ready'); + return; + } + this.navigationRef!.reset({ + index, + routes: routes.map(r => ({ name: r.name, params: r.params })), + }); + } + + /** + * 替换当前路由 + */ + replace(routeName: string, params?: any): void { + if (!this.isNavigationReady()) { + console.warn('[NavigationService] Navigation not ready'); + return; + } + this.navigationRef!.dispatch({ + type: 'REPLACE', + payload: { name: routeName, params }, + }); + } + + /** + * 导航到根路由 + */ + navigateToRoot(): void { + this.reset([{ name: 'Main' }]); + } + + /** + * 导航到认证页面 + */ + navigateToAuth(): void { + this.reset([{ name: 'Auth' }]); + } +} + +export const navigationService = new NavigationService(); diff --git a/src/infrastructure/navigation/types.ts b/src/infrastructure/navigation/types.ts new file mode 100644 index 0000000..065edc6 --- /dev/null +++ b/src/infrastructure/navigation/types.ts @@ -0,0 +1,58 @@ +/** + * 导航基础设施类型定义 + */ + +import { NavigationContainerRef, Route } from '@react-navigation/native'; +import type { + RootStackParamList, + MainTabParamList, + HomeStackParamList, + MessageStackParamList, + ScheduleStackParamList, + ProfileStackParamList, + AuthStackParamList, +} from '../../navigation/types'; + +// ==================== 导航服务类型 ==================== +export interface NavigationServiceInterface { + setNavigationRef(ref: NavigationContainerRef | null): void; + isNavigationReady(): boolean; + navigate(routeName: string, params?: any): void; + goBack(): void; + getCurrentRoute(): Route | null; + getCurrentRouteName(): string | null; + reset(routes: { name: string; params?: any }[], index?: number): void; + replace(routeName: string, params?: any): void; + navigateToRoot(): void; + navigateToAuth(): void; +} + +// ==================== Tab 类型 ==================== +export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; + +export interface NavItemConfig { + name: TabName; + label: string; + icon: string; + iconOutline: string; +} + +// ==================== 导航配置常量 ==================== +export const NAVIGATION_CONSTANTS = { + SIDEBAR_WIDTH_DESKTOP: 240, + SIDEBAR_WIDTH_TABLET: 200, + SIDEBAR_COLLAPSED_WIDTH: 72, + MOBILE_TAB_FLOATING_MARGIN: 12, + LAST_ACTIVE_TAB_KEY: 'last_active_tab', +} as const; + +// ==================== 重新导出导航类型 ==================== +export type { + RootStackParamList, + MainTabParamList, + HomeStackParamList, + MessageStackParamList, + ScheduleStackParamList, + ProfileStackParamList, + AuthStackParamList, +}; diff --git a/src/navigation/AuthNavigator.tsx b/src/navigation/AuthNavigator.tsx new file mode 100644 index 0000000..f1041be --- /dev/null +++ b/src/navigation/AuthNavigator.tsx @@ -0,0 +1,25 @@ +/** + * 认证流程导航 + * 处理登录、注册、忘记密码等认证页面 + */ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import type { AuthStackParamList } from './types'; + +import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth'; + +const AuthStack = createNativeStackNavigator(); + +export function AuthNavigator() { + return ( + + + + + + ); +} diff --git a/src/navigation/DesktopNavigator.tsx b/src/navigation/DesktopNavigator.tsx new file mode 100644 index 0000000..fd77770 --- /dev/null +++ b/src/navigation/DesktopNavigator.tsx @@ -0,0 +1,261 @@ +/** + * 桌面端导航器 + * 为平板/桌面设备提供侧边栏导航体验 + */ +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + StyleSheet, + ScrollView, + TouchableOpacity, + Text, + Animated, + Platform, +} from 'react-native'; +import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import type { TabName, NavItemConfig } from '../infrastructure/navigation/types'; +import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types'; +import { colors, shadows } from '../theme'; +import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState'; + +// 导入屏幕 +import { HomeScreen } from '../screens/home'; +import { ScheduleScreen } from '../screens/schedule'; +import { MessageListScreen } from '../screens/message'; +import { ProfileScreen } from '../screens/profile'; + +// 侧边栏常量 +const { SIDEBAR_WIDTH_DESKTOP, SIDEBAR_WIDTH_TABLET, SIDEBAR_COLLAPSED_WIDTH } = NAVIGATION_CONSTANTS; + +// 导航项配置 +const NAV_ITEMS: NavItemConfig[] = [ + { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, + { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline' }, + { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, + { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, +]; + +interface DesktopNavigatorProps { + unreadCount?: number; +} + +export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { + const insets = useSafeAreaInsets(); + const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState(); + const [isDesktop, setIsDesktop] = useState(false); + + // 检测是否是桌面尺寸 + useEffect(() => { + const checkDesktop = () => { + const width = window.innerWidth || document.documentElement.clientWidth; + setIsDesktop(width >= SIDEBAR_WIDTH_DESKTOP); + }; + + checkDesktop(); + window.addEventListener('resize', checkDesktop); + setIsReady(true); + + return () => window.removeEventListener('resize', checkDesktop); + }, [setIsReady]); + + // 计算侧边栏宽度 + const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : (isDesktop ? SIDEBAR_WIDTH_DESKTOP : SIDEBAR_WIDTH_TABLET); + + // 处理 Tab 切换 + const handleTabChange = useCallback((tab: TabName) => { + setCurrentTab(tab); + }, [setCurrentTab]); + + // 渲染当前 Tab 的内容 + const renderTabContent = () => { + switch (currentTab) { + case 'HomeTab': + return ; + case 'MessageTab': + return ; + case 'ScheduleTab': + return ; + case 'ProfileTab': + return ; + default: + return ; + } + }; + + return ( + + {/* 侧边栏 */} + + {/* Logo 区域 */} + + + {!isCollapsed && ( + 胡萝卜BBS + )} + + + {/* 导航项 */} + + {NAV_ITEMS.map((item) => { + const isActive = currentTab === item.name; + const showBadge = item.name === 'MessageTab' && unreadCount > 0; + + return ( + handleTabChange(item.name)} + activeOpacity={0.7} + > + + + {showBadge && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + {!isCollapsed && ( + + {item.label} + + )} + + ); + })} + + + {/* 折叠按钮 */} + + + + + + {/* 主内容区域 */} + + {renderTabContent()} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + backgroundColor: colors.background.default, + }, + sidebar: { + backgroundColor: colors.background.paper, + borderRightWidth: 1, + borderRightColor: colors.divider, + flexDirection: 'column', + ...shadows.md, + }, + sidebarHeader: { + paddingHorizontal: 16, + paddingVertical: 20, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + logoText: { + fontSize: 18, + fontWeight: '700', + color: colors.primary.main, + marginLeft: 8, + }, + sidebarContent: { + flex: 1, + paddingTop: 8, + }, + sidebarItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + marginHorizontal: 8, + marginVertical: 4, + borderRadius: 12, + }, + sidebarItemActive: { + backgroundColor: `${colors.primary.main}15`, + }, + sidebarItemCollapsed: { + justifyContent: 'center', + paddingHorizontal: 0, + }, + sidebarIconContainer: { + position: 'relative', + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + sidebarLabel: { + fontSize: 15, + fontWeight: '500', + color: colors.text.secondary, + marginLeft: 12, + flex: 1, + }, + sidebarLabelActive: { + color: colors.primary.main, + fontWeight: '600', + }, + collapseButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + paddingHorizontal: 16, + paddingVertical: 12, + borderTopWidth: 1, + borderTopColor: colors.divider, + }, + collapseButtonCollapsed: { + justifyContent: 'center', + paddingHorizontal: 0, + }, + badge: { + position: 'absolute', + top: 2, + right: 2, + backgroundColor: colors.error.main, + borderRadius: 10, + minWidth: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 4, + }, + badgeText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, + mainContent: { + flex: 1, + backgroundColor: colors.background.default, + }, +}); diff --git a/src/navigation/HomeNavigator.tsx b/src/navigation/HomeNavigator.tsx new file mode 100644 index 0000000..2589651 --- /dev/null +++ b/src/navigation/HomeNavigator.tsx @@ -0,0 +1,49 @@ +/** + * 首页 Stack 导航 + * 处理首页相关页面:首页、搜索等 + */ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import type { HomeStackParamList } from './types'; + +import { colors } from '../theme'; +import { HomeScreen, SearchScreen } from '../screens/home'; + +const HomeStack = createNativeStackNavigator(); + +export function HomeNavigator() { + return ( + + + + + ); +} diff --git a/src/navigation/MainNavigator.tsx b/src/navigation/MainNavigator.tsx index 7173a1f..4d0dbea 100644 --- a/src/navigation/MainNavigator.tsx +++ b/src/navigation/MainNavigator.tsx @@ -1,1118 +1,100 @@ /** - * 主导航组件 - * 配置底部Tab导航和Stack导航 - * 根据登录状态自动切换AuthStack和MainStack - * 支持响应式布局:移动端底部导航,平板/桌面端侧边导航 + * 主导航组件(重构版) + * + * 此文件仅保留导航器组装逻辑,所有业务逻辑已解耦至: + * - RootNavigator: 处理根导航和认证状态 + * - DesktopNavigator: 处理桌面端侧边栏导航 + * - SimpleMobileTabNavigator: 处理移动端 Tab 导航 + * - navigationService: 提供全局导航方法 + * + * 原文件 1118 行已精简至约 200 行 */ -import React, { useEffect, useState, useCallback } from 'react'; -import { - StyleSheet, - View, - ActivityIndicator, - TouchableOpacity, - Animated, - ScrollView, - Text as RNText, - Platform, -} from 'react-native'; -import { - NavigationContainer, - LinkingOptions, - NavigatorScreenParams, - RouteProp, - useNavigation, - useRoute, -} from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useTheme } from 'react-native-paper'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; +import React, { useEffect, useState } from 'react'; +import { LinkingOptions } from '@react-navigation/native'; +import { Platform } from 'react-native'; -import { colors, shadows } from '../theme'; -import { useAuthStore, useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; -import { useResponsive } from '../hooks'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -} from './types'; -import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; +import type { RootStackParamList } from './types'; +import { RootNavigator } from './RootNavigator'; -// ==================== 导入屏幕组件 ==================== -import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - ChatScreen, - NotificationsScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - GroupInfoScreen, - GroupMembersScreen, - PrivateChatInfoScreen -} from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; -import { CreatePostScreen } from '../screens/create'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; -import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth'; +// 导入认证 Store +import { useAuthStore } from '../stores'; -// ==================== Stack Navigators ==================== -const RootStack = createNativeStackNavigator(); -const AuthStack = createNativeStackNavigator(); -const HomeStack = createNativeStackNavigator(); -const MessageStack = createNativeStackNavigator(); -const ScheduleStack = createNativeStackNavigator(); -const ProfileStack = createNativeStackNavigator(); -const Tab = createBottomTabNavigator(); - -// ==================== 导航栏宽度常量 ==================== -const SIDEBAR_WIDTH_DESKTOP = 240; -const SIDEBAR_WIDTH_TABLET = 200; -const SIDEBAR_COLLAPSED_WIDTH = 72; -const MOBILE_TAB_FLOATING_MARGIN = 12; - -// ==================== 持久化存储键 ==================== -const LAST_ACTIVE_TAB_KEY = 'last_active_tab'; - -// ==================== 导航项类型 ==================== -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -type IconName = React.ComponentProps['name']; - -interface NavItem { - name: TabName; - label: string; - icon: IconName; - iconOutline: IconName; - badge?: number; -} - -// ==================== AuthStack导航 ==================== -function AuthStackNavigator() { - return ( - - - - - - ); -} - -// ==================== 首页Stack导航 ==================== -function HomeStackNavigator() { - const paperTheme = useTheme(); - - return ( - = { + prefixes: [], + config: { + screens: { + Auth: { + screens: { + Login: 'login', + Register: 'register', + ForgotPassword: 'forgot-password', }, - headerTintColor: colors.text.primary, - headerTitleStyle: { - fontWeight: '600', - }, - headerBackTitle: '', - headerShadowVisible: false, - }} - > - - - - ); -} - -// ==================== 消息Stack导航 ==================== -function MessageStackNavigator() { - return ( - - - - - - ); -} - -// ==================== 个人中心Stack导航 ==================== -function ProfileStackNavigator() { - return ( - - - - - - - - - - - ); -} - -// ==================== 侧边导航栏组件 ==================== -interface SidebarProps { - activeTab: TabName; - onTabChange: (tab: TabName) => void; - unreadCount: number; - isCollapsed: boolean; - onToggleCollapse: () => void; - width: number; -} - -function Sidebar({ - activeTab, - onTabChange, - unreadCount, - isCollapsed, - onToggleCollapse, - width, -}: SidebarProps) { - const insets = useSafeAreaInsets(); - - // 调试日志 - useEffect(() => { - console.log('[Debug Sidebar]', { - width, - isCollapsed, - activeTab, - unreadCount, - insetsTop: insets.top, - insetsBottom: insets.bottom, - }); - }, [width, isCollapsed, activeTab, unreadCount, insets]); - - const navItems: NavItem[] = [ - { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, - { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount }, - { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, - { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, - ]; - - return ( - - {/* Logo/品牌区域 */} - - {!isCollapsed && ( - - - - 胡萝卜BBS - - - )} - {isCollapsed && ( - - )} - - - {/* 导航项 */} - - {navItems.map((item) => { - const isActive = activeTab === item.name; - const showBadge = !!(item.badge && item.badge > 0); - - return ( - onTabChange(item.name)} - activeOpacity={0.7} - > - - - {showBadge && ( - - - {item.badge! > 99 ? '99+' : item.badge} - - - )} - - {!isCollapsed && ( - - {item.label} - - )} - - ); - })} - - - {/* 底部折叠按钮 */} - - - - - ); -} - -// ==================== Text 组件 ==================== -function Text({ style, children, numberOfLines }: { style?: any; children: React.ReactNode; numberOfLines?: number }) { - return ( - - {children} - - ); -} - -// ==================== 课程表Stack导航 ==================== -function ScheduleStackNavigator() { - return ( - - - - - ); -} - -// ==================== 响应式 Tab Navigator ==================== -interface ResponsiveTabNavigatorProps { - children?: React.ReactNode; -} - -function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { - const navigation = useNavigation>(); - const route = useRoute>(); - const { isMobile, isTablet, isDesktop, width, height } = useResponsive(); - const insets = useSafeAreaInsets(); - const [activeTab, setActiveTab] = useState('HomeTab'); - const [isCollapsed, setIsCollapsed] = useState(false); - const messageUnreadCount = useTotalUnreadCount(); - - - // 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新 - useEffect(() => { - messageManager.initialize(); - }, []); - - // 同步 URL 中的 Main 子路由到桌面侧边栏激活态 - useEffect(() => { - const targetTab = route.params?.screen; - if (!targetTab) return; - if ((targetTab === 'HomeTab' || targetTab === 'MessageTab' || targetTab === 'ScheduleTab' || targetTab === 'ProfileTab') && targetTab !== activeTab) { - setActiveTab(targetTab); - } - }, [route.params?.screen, activeTab]); - - // 计算侧边栏宽度 - const sidebarWidth = useCallback(() => { - if (isMobile) return 0; // 移动端不显示侧边栏 - if (isCollapsed) return SIDEBAR_COLLAPSED_WIDTH; - if (isDesktop) return SIDEBAR_WIDTH_DESKTOP; - if (isTablet) return SIDEBAR_WIDTH_TABLET; - // 默认返回桌面宽度,避免返回 0 导致导航栏消失 - return SIDEBAR_WIDTH_DESKTOP; - }, [isCollapsed, isDesktop, isTablet, isMobile]); - - // 切换 Tab - const handleTabChange = useCallback((tab: TabName) => { - setActiveTab(tab); - // 在大屏幕模式下,通过导航切换 Tab - if (!isMobile) { - let tabTarget: NavigatorScreenParams; - if (tab === 'HomeTab') { - tabTarget = { screen: 'HomeTab', params: { screen: 'Home' } }; - } else if (tab === 'MessageTab') { - tabTarget = { screen: 'MessageTab', params: { screen: 'MessageList' } }; - } else if (tab === 'ScheduleTab') { - tabTarget = { screen: 'ScheduleTab', params: { screen: 'Schedule' } }; - } else { - tabTarget = { screen: 'ProfileTab', params: { screen: 'Profile' } }; - } - navigation.navigate('Main', tabTarget); - } - }, [navigation, isMobile]); - - // 切换折叠状态 - const handleToggleCollapse = useCallback(() => { - setIsCollapsed(prev => !prev); - }, []); - - // 移动端:使用底部 Tab 导航 - if (isMobile) { - // 使用简化的移动端导航,避免 React Navigation 状态恢复问题 - return ; - } - - // 渲染当前 Tab 的内容(仅在大屏模式下使用) - // 注意:直接渲染屏幕组件,不使用 Stack Navigators - // 这样可以避免 React Navigation 状态恢复问题 - const renderTabContent = () => { - switch (activeTab) { - case 'HomeTab': - return ; - case 'MessageTab': - return ; - case 'ScheduleTab': - return ; - case 'ProfileTab': - return ; - default: - return ; - } - }; - - // 平板/桌面端:使用侧边导航 - const currentSidebarWidth = sidebarWidth(); - - return ( - - {/* 侧边导航栏 */} - - - {/* 主内容区域 */} - - {renderTabContent()} - - - ); -} - -// ==================== 底部Tab导航(移动端)==================== -function MainTabNavigator() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - - // 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新 - useEffect(() => { - messageManager.initialize(); - }, []); - - return ( - - ( - - - - ), - }} - /> - 0 ? (messageUnreadCount > 99 ? '99+' : messageUnreadCount) : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} + }, + PostDetail: 'posts/:postId', + UserProfile: 'users/:userId', + CreatePost: 'posts/create', + Chat: 'chat/:conversationId', + FollowList: 'users/:userId/:type', + }, + }, +}; -// ==================== 根导航 ==================== +/** + * 主导航组件 + * + * 职责: + * 1. 初始化认证状态 + * 2. 根据认证状态渲染 RootNavigator + * 3. 配置 Deep Linking + */ export default function MainNavigator() { const { isAuthenticated, fetchCurrentUser } = useAuthStore(); - const [initializing, setInitializing] = useState(true); + const [isInitializing, setIsInitializing] = useState(true); - const linking: LinkingOptions = { - prefixes: [], - config: { - screens: { - Auth: { - screens: { - Login: 'login', - Register: 'register', - ForgotPassword: 'forgot-password', - }, - }, - Main: { - screens: { - HomeTab: { - screens: { - Home: 'home', - Search: 'search', - }, - }, - MessageTab: { - screens: { - MessageList: 'messages', - Notifications: 'notifications', - }, - }, - ProfileTab: { - screens: { - Profile: 'me', - Settings: 'settings', - EditProfile: 'me/edit', - AccountSecurity: 'me/security', - NotificationSettings: 'me/notifications', - BlockedUsers: 'me/blocked', - }, - }, - }, - }, - PostDetail: 'posts/:postId', - UserProfile: 'users/:userId', - CreatePost: 'posts/create', - Chat: 'chat/:conversationId', - FollowList: 'users/:userId/:type', - }, - }, - }; - - // 初始化时检查登录状态 + // 初始化认证状态 useEffect(() => { const initAuth = async () => { await fetchCurrentUser(); - setInitializing(false); + setIsInitializing(false); }; initAuth(); }, [fetchCurrentUser]); - // 加载中显示 - if (initializing) { - return ( - - - - ); - } - return ( - - - {/* 根据登录状态显示不同Stack */} - {isAuthenticated ? ( - <> - - - - - - - - - - - - - - - ) : ( - <> - - {/* 未登录时也可以查看帖子详情 */} - - - - )} - - + ); } -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - // 响应式容器 - responsiveContainer: { - flex: 1, - flexDirection: 'row', - backgroundColor: colors.background.default, - }, - - // 侧边栏样式 - sidebar: { - backgroundColor: colors.background.paper, - borderRightWidth: 1, - borderRightColor: colors.divider, - flexDirection: 'column', - ...shadows.md, - }, - sidebarHeader: { - paddingHorizontal: 16, - paddingVertical: 20, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - alignItems: 'center', - justifyContent: 'center', - }, - logoContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - logoText: { - fontSize: 20, - fontWeight: '700', - color: colors.primary.main, - marginLeft: 12, - }, - sidebarContent: { - flex: 1, - paddingTop: 8, - }, - sidebarItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - marginHorizontal: 8, - marginVertical: 4, - borderRadius: 12, - }, - sidebarItemActive: { - backgroundColor: `${colors.primary.main}15`, - }, - sidebarItemCollapsed: { - justifyContent: 'center', - paddingHorizontal: 0, - }, - sidebarIconContainer: { - position: 'relative', - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 12, - }, - sidebarLabel: { - fontSize: 15, - fontWeight: '500', - color: colors.text.secondary, - marginLeft: 12, - flex: 1, - }, - sidebarLabelActive: { - color: colors.primary.main, - fontWeight: '600', - }, - collapseButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-end', - paddingHorizontal: 16, - paddingVertical: 12, - borderTopWidth: 1, - borderTopColor: colors.divider, - }, - collapseButtonCollapsed: { - justifyContent: 'center', - paddingHorizontal: 0, - }, - - // 徽章样式 - badge: { - position: 'absolute', - top: 2, - right: 2, - backgroundColor: colors.error.main, - borderRadius: 10, - minWidth: 18, - height: 18, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 4, - }, - badgeText: { - color: colors.primary.contrast, - fontSize: 10, - fontWeight: '600', - }, - - // 主内容区域 - mainContent: { - flex: 1, - backgroundColor: colors.background.default, - }, - - // 底部 Tab 样式 - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, -}); +// 导出 linking 配置供外部使用 +export { linking }; diff --git a/src/navigation/MessageNavigator.tsx b/src/navigation/MessageNavigator.tsx new file mode 100644 index 0000000..cb343c5 --- /dev/null +++ b/src/navigation/MessageNavigator.tsx @@ -0,0 +1,60 @@ +/** + * 消息 Stack 导航 + * 处理消息相关页面:消息列表、通知等 + */ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import type { MessageStackParamList } from './types'; + +import { colors } from '../theme'; +import { + MessageListScreen, + NotificationsScreen, + PrivateChatInfoScreen, +} from '../screens/message'; + +const MessageStack = createNativeStackNavigator(); + +export function MessageNavigator() { + return ( + + + + + + ); +} diff --git a/src/navigation/ProfileNavigator.tsx b/src/navigation/ProfileNavigator.tsx new file mode 100644 index 0000000..0d57373 --- /dev/null +++ b/src/navigation/ProfileNavigator.tsx @@ -0,0 +1,101 @@ +/** + * 个人中心 Stack 导航 + * 处理个人中心相关页面 + */ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import type { ProfileStackParamList } from './types'; + +import { colors } from '../theme'; +import { + ProfileScreen, + SettingsScreen, + EditProfileScreen, + NotificationSettingsScreen, + BlockedUsersScreen, + AccountSecurityScreen, +} from '../screens/profile'; + +const ProfileStack = createNativeStackNavigator(); + +export function ProfileNavigator() { + return ( + + + + + + + + + + + ); +} diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx new file mode 100644 index 0000000..871dbcb --- /dev/null +++ b/src/navigation/RootNavigator.tsx @@ -0,0 +1,265 @@ +/** + * 根导航器 + * 处理整个应用的顶级导航,包括认证状态切换 + */ +import React, { useEffect, useState } from 'react'; +import { View, ActivityIndicator, StyleSheet } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import type { RootStackParamList } from './types'; +import { colors } from '../theme'; +import { navigationService } from '../infrastructure/navigation/navigationService'; + +import { AuthNavigator } from './AuthNavigator'; +import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; +import { DesktopNavigator } from './DesktopNavigator'; + +import { useResponsive } from '../hooks'; +import { useTotalUnreadCount } from '../stores'; + +// 导入全局屏幕组件 +import { PostDetailScreen } from '../screens/home'; +import { UserScreen } from '../screens/profile'; +import FollowListScreen from '../screens/profile/FollowListScreen'; +import { CreatePostScreen } from '../screens/create'; +import { + ChatScreen, + CreateGroupScreen, + JoinGroupScreen, + GroupInfoScreen, + GroupMembersScreen, + GroupRequestDetailScreen, + GroupInviteDetailScreen, + PrivateChatInfoScreen, +} from '../screens/message'; + +const RootStack = createNativeStackNavigator(); + +interface RootNavigatorProps { + isAuthenticated: boolean; + isInitializing: boolean; +} + +// 未认证时可访问的屏幕 +const PublicScreens = () => ( + <> + + + +); + +// 认证后可访问的屏幕 +const AuthenticatedScreens = () => ( + <> + + + + + + + + + + + + + +); + +export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) { + const { isMobile } = useResponsive(); + const unreadCount = useTotalUnreadCount(); + + // 设置导航引用 + const setNavigationRef = (ref: any) => { + navigationService.setNavigationRef(ref); + }; + + // 加载中显示 + if (isInitializing) { + return ( + + + + ); + } + + return ( + + + {isAuthenticated ? ( + <> + + {() => + isMobile ? ( + + ) : ( + + ) + } + + + + ) : ( + <> + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, +}); diff --git a/src/navigation/ScheduleNavigator.tsx b/src/navigation/ScheduleNavigator.tsx new file mode 100644 index 0000000..30676aa --- /dev/null +++ b/src/navigation/ScheduleNavigator.tsx @@ -0,0 +1,42 @@ +/** + * 课程表 Stack 导航 + * 处理课程表相关页面 + */ +import React from 'react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import type { ScheduleStackParamList } from './types'; + +import { colors } from '../theme'; +import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; + +const ScheduleStack = createNativeStackNavigator(); + +export function ScheduleNavigator() { + return ( + + + + + ); +} diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx new file mode 100644 index 0000000..771cd07 --- /dev/null +++ b/src/navigation/TabNavigator.tsx @@ -0,0 +1,155 @@ +/** + * Tab 导航器 + * 处理底部标签导航(移动端) + */ +import React from 'react'; +import { View } from 'react-native'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import type { MainTabParamList } from './types'; +import { colors, shadows } from '../theme'; + +import { HomeNavigator } from './HomeNavigator'; +import { MessageNavigator } from './MessageNavigator'; +import { ScheduleNavigator } from './ScheduleNavigator'; +import { ProfileNavigator } from './ProfileNavigator'; + +const Tab = createBottomTabNavigator(); + +// 常量 +const MOBILE_TAB_FLOATING_MARGIN = 12; + +interface TabNavigatorProps { + unreadCount: number; +} + +export function TabNavigator({ unreadCount }: TabNavigatorProps) { + const insets = useSafeAreaInsets(); + + return ( + + ( + + + + ), + }} + /> + 0 ? (unreadCount > 99 ? '99+' : unreadCount) : undefined, + tabBarIcon: ({ color, size, focused }) => ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + + ); +} + +import { StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + tabIconContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 38, + height: 30, + borderRadius: 15, + }, + tabIconActive: { + backgroundColor: `${colors.primary.main}20`, + transform: [{ translateY: -1 }], + }, +}); diff --git a/src/navigation/index.ts b/src/navigation/index.ts new file mode 100644 index 0000000..f7462b5 --- /dev/null +++ b/src/navigation/index.ts @@ -0,0 +1,30 @@ +/** + * 导出所有导航组件 + */ + +// 类型 +export type { + RootStackParamList, + MainTabParamList, + HomeStackParamList, + MessageStackParamList, + ScheduleStackParamList, + ProfileStackParamList, + AuthStackParamList, + TabName, + NavItemConfig, +} from './types'; + +// 导航器 +export { AuthNavigator } from './AuthNavigator'; +export { HomeNavigator } from './HomeNavigator'; +export { MessageNavigator } from './MessageNavigator'; +export { ScheduleNavigator } from './ScheduleNavigator'; +export { ProfileNavigator } from './ProfileNavigator'; +export { TabNavigator } from './TabNavigator'; +export { DesktopNavigator } from './DesktopNavigator'; +export { RootNavigator } from './RootNavigator'; +export { MainNavigator } from './MainNavigator'; + +// 移动端简化导航 +export { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 6cbc7fa..450f8ef 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -121,3 +121,14 @@ export type MessageScreenNames = keyof MessageStackParamList; export type ProfileScreenNames = keyof ProfileStackParamList; export type MainTabScreenNames = keyof MainTabParamList; export type RootScreenNames = keyof RootStackParamList; + +// ==================== Tab 类型 ==================== +export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; + +// ==================== 导航项配置 ==================== +export interface NavItemConfig { + name: TabName; + label: string; + icon: string; + iconOutline: string; +} diff --git a/src/presentation/hooks/responsive/MIGRATION.md b/src/presentation/hooks/responsive/MIGRATION.md new file mode 100644 index 0000000..7eb181c --- /dev/null +++ b/src/presentation/hooks/responsive/MIGRATION.md @@ -0,0 +1,223 @@ +# useResponsive 拆分迁移指南 + +## 概述 + +原有的 `useResponsive.ts` (485行) 已被拆分为多个专注的 hooks,以提高代码的可维护性和性能。 + +## 新目录结构 + +``` +src/presentation/hooks/responsive/ +├── types.ts # 共享类型定义 +├── useBreakpoint.ts # 断点检测 +├── useScreenSize.ts # 屏幕尺寸 +├── useOrientation.ts # 方向检测 +├── usePlatform.ts # 平台检测 +├── useResponsiveValue.ts # 响应式值映射 +├── useResponsiveSpacing.ts # 响应式间距 +├── useColumnCount.ts # 列数计算 +├── useMediaQuery.ts # 媒体查询 +├── useBreakpointCheck.ts # 断点范围检查 (兼容层) +├── useResponsive.ts # 兼容层 (保持向后兼容) +└── index.ts # 统一导出 +``` + +## 新 Hook API 说明 + +### useBreakpoint - 断点检测 + +```typescript +import { useBreakpoint, useFineBreakpoint } from '../presentation/hooks/responsive'; + +// 基础断点: 'mobile' | 'tablet' | 'desktop' | 'wide' +const breakpoint = useBreakpoint(); + +// 细粒度断点: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' +const fineBreakpoint = useFineBreakpoint(); +``` + +### useScreenSize - 屏幕尺寸 + +```typescript +import { useScreenSize, useWindowDimensions } from '../presentation/hooks/responsive'; + +// 完整屏幕尺寸信息 +const { + width, height, + breakpoint, fineBreakpoint, + isMobile, isTablet, isDesktop, isWide +} = useScreenSize(); + +// 仅获取窗口尺寸 +const { width, height, scale, fontScale } = useWindowDimensions(); +``` + +### useOrientation - 方向检测 + +```typescript +import { useOrientation } from '../presentation/hooks/responsive'; + +const { orientation, isPortrait, isLandscape } = useOrientation(); +``` + +### usePlatform - 平台检测 + +```typescript +import { usePlatform } from '../presentation/hooks/responsive'; + +const { OS, isWeb, isIOS, isAndroid, isNative } = usePlatform(); +``` + +### useResponsiveValue - 响应式值 + +```typescript +import { useResponsiveValue } from '../presentation/hooks/responsive'; + +// 根据断点返回不同值 +const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); + +// 响应式样式 +const style = useResponsiveStyle({ + padding: { xs: 8, md: 16, lg: 24 }, + fontSize: { xs: 14, lg: 16 } +}); +``` + +### useResponsiveSpacing - 响应式间距 + +```typescript +import { useResponsiveSpacing } from '../presentation/hooks/responsive'; + +const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); +``` + +### useColumnCount - 列数计算 + +```typescript +import { useColumnCount } from '../presentation/hooks/responsive'; + +const columns = useColumnCount({ xs: 1, sm: 2, md: 3, lg: 4 }); +``` + +### useBreakpointGTE / useBreakpointLT - 断点范围检查 + +```typescript +import { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from '../presentation/hooks/responsive'; + +const isMediumUp = useBreakpointGTE('md'); +const isMobileOnly = useBreakpointLT('lg'); +const isTabletRange = useBreakpointBetween('md', 'lg'); +``` + +### useMediaQuery - 媒体查询 + +```typescript +import { useMediaQuery } from '../presentation/hooks/responsive'; + +const isMinWidth768 = useMediaQuery({ minWidth: 768 }); +const isPortrait = useMediaQuery({ orientation: 'portrait' }); +``` + +## 迁移示例 + +### 示例 1: 使用 useResponsive (旧) + +```typescript +import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../hooks'; + +function Component() { + const { width, isMobile, isTablet, isDesktop, isWide } = useResponsive(); + const padding = useResponsiveSpacing({ xs: 8, md: 16 }); + + return ; +} +``` + +### 示例 2: 使用新专注 hooks (推荐) + +```typescript +import { + useScreenSize, + useResponsiveSpacing +} from '../presentation/hooks/responsive'; + +function Component() { + const { width, isMobile, isTablet, isDesktop, isWide } = useScreenSize(); + const padding = useResponsiveSpacing({ xs: 8, md: 16 }); + + return ; +} +``` + +### 示例 3: 仅使用部分功能 (性能优化) + +```typescript +import { useBreakpoint, usePlatform } from '../presentation/hooks/responsive'; + +function Component() { + // 只订阅断点变化,不订阅尺寸变化 + const breakpoint = useBreakpoint(); + const { isIOS } = usePlatform(); // 平台不会变化,只计算一次 + + return ; +} +``` + +## 性能优势 + +1. **按需订阅**: 只使用需要的功能,避免不必要的重渲染 +2. **细粒度更新**: 每个 hook 独立管理状态 +3. **平台常量**: usePlatform 返回常量,不会触发重渲染 + +## 向后兼容 + +原有的 `useResponsive` 仍然可用,但标记为 `@deprecated`: + +```typescript +import { useResponsive } from '../hooks'; // 仍然可用 + +function Component() { + const { width, height, isMobile, platform } = useResponsive(); // 仍然可用 + return ; +} +``` + +## 工具函数 + +```typescript +import { + getBreakpoint, + getFineBreakpoint, + isBreakpointGTE, + isBreakpointLT +} from '../presentation/hooks/responsive'; + +// 非 hooks 版本,用于工具函数 +const breakpoint = getBreakpoint(800); +const fineBreakpoint = getFineBreakpoint(800); +const isGTE = isBreakpointGTE('md', 'sm'); +``` + +## 类型导出 + +```typescript +import type { + BreakpointKey, + FineBreakpointKey, + ResponsiveValue, + Orientation, + PlatformInfo, + ScreenSize, + MediaQueryOptions, + ResponsiveInfo, // 兼容层 +} from '../presentation/hooks/responsive'; +``` + +## 常量 + +```typescript +import { BREAKPOINTS, FINE_BREAKPOINTS } from '../presentation/hooks/responsive'; + +// BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 } +// FINE_BREAKPOINTS = { xs: 0, sm: 375, md: 414, lg: 768, xl: 1024, '2xl': 1280, '3xl': 1440, '4xl': 1920 } +``` diff --git a/src/presentation/hooks/responsive/index.ts b/src/presentation/hooks/responsive/index.ts new file mode 100644 index 0000000..00aded3 --- /dev/null +++ b/src/presentation/hooks/responsive/index.ts @@ -0,0 +1,66 @@ +/** + * 响应式 Hooks 统一导出 + * Responsive Hooks Index + * + * 提供完整的响应式设计解决方案,包括: + * - 断点检测 (useBreakpoint) + * - 屏幕尺寸 (useScreenSize, useWindowDimensions) + * - 响应式值 (useResponsiveValue, useResponsiveStyle) + * - 方向检测 (useOrientation) + * - 平台检测 (usePlatform) + * - 媒体查询 (useMediaQuery) + * - 列数计算 (useColumnCount) + * - 间距计算 (useResponsiveSpacing) + * - 断点检查 (useBreakpointGTE, useBreakpointLT, useBreakpointBetween) + */ + +// ==================== 核心 Hooks ==================== +export { useWindowDimensions, useScreenSize } from './useScreenSize'; +export { + useBreakpoint, + useFineBreakpoint, + useBreakpointGTE, + useBreakpointLT, + useBreakpointBetween, +} from './useBreakpoint'; +export { useResponsiveValue, useResponsiveStyle } from './useResponsiveValue'; +export { useOrientation } from './useOrientation'; +export { usePlatform } from './usePlatform'; +export { useMediaQuery } from './useMediaQuery'; +export { useColumnCount } from './useColumnCount'; +export { useResponsiveSpacing } from './useResponsiveSpacing'; + +// ==================== 工具函数 ==================== +export { + getBreakpoint, + getFineBreakpoint, + isBreakpointGTE, + isBreakpointLT, + isBreakpointBetween, +} from './useBreakpoint'; + +// ==================== 类型 ==================== +export type { + BreakpointKey, + FineBreakpointKey, + BreakpointValue, + ResponsiveValue, + Orientation, + PlatformInfo, + ScreenSize, + MediaQueryOptions, +} from './types'; + +export { + BREAKPOINTS, + FINE_BREAKPOINTS, +} from './types'; + +// 兼容层类型 +export type { ResponsiveInfo } from './useResponsive'; + +// ==================== 向后兼容 ==================== +export { useResponsive, useLegacyResponsive } from './useResponsive'; + +// 默认导出 +export { useScreenSize as default } from './useScreenSize'; diff --git a/src/presentation/hooks/responsive/types.ts b/src/presentation/hooks/responsive/types.ts new file mode 100644 index 0000000..be29db2 --- /dev/null +++ b/src/presentation/hooks/responsive/types.ts @@ -0,0 +1,62 @@ +/** + * 响应式相关类型定义 + */ + +// ==================== 断点定义 ==================== +export const BREAKPOINTS = { + mobile: 0, // 手机 + tablet: 768, // 平板竖屏 + desktop: 1024, // 平板横屏/桌面 + wide: 1440, // 宽屏桌面 +} as const; + +export type BreakpointKey = keyof typeof BREAKPOINTS; +export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey]; + +// ==================== 更细粒度的断点定义 ==================== +export const FINE_BREAKPOINTS = { + xs: 0, // 超小屏手机 + sm: 375, // 小屏手机 (iPhone SE, 小屏安卓) + md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓) + lg: 768, // 平板竖屏 / 大折叠屏手机展开 + xl: 1024, // 平板横屏 / 小桌面 + '2xl': 1280, // 桌面 + '3xl': 1440, // 大桌面 + '4xl': 1920, // 超大屏 +} as const; + +export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS; + +// ==================== 响应式值类型 ==================== +export type ResponsiveValue = T | Partial>; + +// ==================== 方向类型 ==================== +export type Orientation = 'portrait' | 'landscape'; + +// ==================== 平台类型 ==================== +export interface PlatformInfo { + OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; + isWeb: boolean; + isIOS: boolean; + isAndroid: boolean; + isNative: boolean; +} + +// ==================== 屏幕尺寸类型 ==================== +export interface ScreenSize { + width: number; + height: number; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; +} + +// ==================== 媒体查询选项 ==================== +export interface MediaQueryOptions { + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + orientation?: Orientation; +} diff --git a/src/presentation/hooks/responsive/useBreakpoint.ts b/src/presentation/hooks/responsive/useBreakpoint.ts new file mode 100644 index 0000000..8a2bdb9 --- /dev/null +++ b/src/presentation/hooks/responsive/useBreakpoint.ts @@ -0,0 +1,177 @@ +/** + * useBreakpoint Hook + * 断点检测 - 检测当前断点 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import { BREAKPOINTS, FINE_BREAKPOINTS } from './types'; +import type { BreakpointKey, FineBreakpointKey } from './types'; + +// ==================== 工具函数 ==================== + +/** + * 获取当前断点 + */ +export function getBreakpoint(width: number): BreakpointKey { + if (width >= BREAKPOINTS.wide) { + return 'wide'; + } + if (width >= BREAKPOINTS.desktop) { + return 'desktop'; + } + if (width >= BREAKPOINTS.tablet) { + return 'tablet'; + } + return 'mobile'; +} + +/** + * 获取细粒度断点 + */ +export function getFineBreakpoint(width: number): FineBreakpointKey { + if (width >= FINE_BREAKPOINTS['4xl']) return '4xl'; + if (width >= FINE_BREAKPOINTS['3xl']) return '3xl'; + if (width >= FINE_BREAKPOINTS['2xl']) return '2xl'; + if (width >= FINE_BREAKPOINTS.xl) return 'xl'; + if (width >= FINE_BREAKPOINTS.lg) return 'lg'; + if (width >= FINE_BREAKPOINTS.md) return 'md'; + if (width >= FINE_BREAKPOINTS.sm) return 'sm'; + return 'xs'; +} + +/** + * 断点比较 - 检查当前断点是否大于等于目标断点 + */ +export function isBreakpointGTE( + current: FineBreakpointKey, + target: FineBreakpointKey +): boolean { + const order: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl']; + return order.indexOf(current) >= order.indexOf(target); +} + +/** + * 断点比较 - 检查当前断点是否小于目标断点 + */ +export function isBreakpointLT( + current: FineBreakpointKey, + target: FineBreakpointKey +): boolean { + return !isBreakpointGTE(current, target); +} + +/** + * 检查当前是否在指定断点范围内 + */ +export function isBreakpointBetween( + current: FineBreakpointKey, + min: FineBreakpointKey, + max: FineBreakpointKey +): boolean { + return isBreakpointGTE(current, min) && isBreakpointLT(current, max); +} + +// ==================== Hooks ==================== + +/** + * 断点检测 Hook + * 返回当前的基础断点 + * + * @returns 当前断点 + * + * @example + * const breakpoint = useBreakpoint(); + * // 'mobile' | 'tablet' | 'desktop' | 'wide' + */ +export function useBreakpoint(): BreakpointKey { + const { width } = useWindowDimensions(); + + return useMemo(() => { + return getBreakpoint(width); + }, [width]); +} + +/** + * 细粒度断点检测 Hook + * 返回当前的细粒度断点 + * + * @returns 当前细粒度断点 + * + * @example + * const fineBreakpoint = useFineBreakpoint(); + * // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' + */ +export function useFineBreakpoint(): FineBreakpointKey { + const { width } = useWindowDimensions(); + + return useMemo(() => { + return getFineBreakpoint(width); + }, [width]); +} + +/** + * 检查当前断点是否大于等于目标断点 + * + * @param target - 目标断点 + * @returns 是否满足条件 + * + * @example + * const isMediumUp = useBreakpointGTE('md'); + */ +export function useBreakpointGTE(target: FineBreakpointKey): boolean { + const { width } = useWindowDimensions(); + + return useMemo(() => { + const current = getFineBreakpoint(width); + return isBreakpointGTE(current, target); + }, [width, target]); +} + +/** + * 检查当前断点是否小于目标断点 + * + * @param target - 目标断点 + * @returns 是否满足条件 + * + * @example + * const isMobileOnly = useBreakpointLT('lg'); + */ +export function useBreakpointLT(target: FineBreakpointKey): boolean { + const { width } = useWindowDimensions(); + + return useMemo(() => { + const current = getFineBreakpoint(width); + return isBreakpointLT(current, target); + }, [width, target]); +} + +/** + * 检查当前是否在指定断点范围内 + * + * @param min - 最小断点(包含) + * @param max - 最大断点(不包含) + * @returns 是否在范围内 + * + * @example + * const isTabletRange = useBreakpointBetween('md', 'lg'); + */ +export function useBreakpointBetween( + min: FineBreakpointKey, + max: FineBreakpointKey +): boolean { + const { width } = useWindowDimensions(); + + return useMemo(() => { + const current = getFineBreakpoint(width); + return isBreakpointBetween(current, min, max); + }, [width, min, max]); +} + +export default { + useBreakpoint, + useFineBreakpoint, + useBreakpointGTE, + useBreakpointLT, + useBreakpointBetween, +}; diff --git a/src/presentation/hooks/responsive/useBreakpointCheck.ts b/src/presentation/hooks/responsive/useBreakpointCheck.ts new file mode 100644 index 0000000..14f0da6 --- /dev/null +++ b/src/presentation/hooks/responsive/useBreakpointCheck.ts @@ -0,0 +1,7 @@ +/** + * useBreakpointCheck Hook + * 断点检查 - 提供断点范围检查功能 + * @deprecated 请直接使用 useBreakpoint.ts 中的 hooks + */ + +export { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from './useBreakpoint'; diff --git a/src/presentation/hooks/responsive/useColumnCount.ts b/src/presentation/hooks/responsive/useColumnCount.ts new file mode 100644 index 0000000..e7ae4a1 --- /dev/null +++ b/src/presentation/hooks/responsive/useColumnCount.ts @@ -0,0 +1,59 @@ +/** + * useColumnCount Hook + * 列数计算 - 根据容器宽度计算合适的列数 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import { getFineBreakpoint } from './useBreakpoint'; +import type { FineBreakpointKey } from './types'; + +const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + +const defaultConfig: Record = { + xs: 1, + sm: 1, + md: 2, + lg: 3, + xl: 4, + '2xl': 4, + '3xl': 5, + '4xl': 6, +}; + +/** + * 根据容器宽度计算合适的列数 + * + * @param columnConfig - 列数配置 + * @returns 列数 + * + * @example + * const columns = useColumnCount({ + * xs: 1, + * sm: 2, + * md: 3, + * lg: 4 + * }); + */ +export function useColumnCount( + columnConfig: Partial> = {} +): number { + const { width } = useWindowDimensions(); + const fineBreakpoint = getFineBreakpoint(width); + + return useMemo(() => { + const config = { ...defaultConfig, ...columnConfig }; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (config[bp] !== undefined) { + return config[bp]; + } + } + + return 1; + }, [fineBreakpoint, columnConfig]); +} + +export default useColumnCount; diff --git a/src/presentation/hooks/responsive/useMediaQuery.ts b/src/presentation/hooks/responsive/useMediaQuery.ts new file mode 100644 index 0000000..141bb4a --- /dev/null +++ b/src/presentation/hooks/responsive/useMediaQuery.ts @@ -0,0 +1,36 @@ +/** + * useMediaQuery Hook + * 媒体查询模拟 - 模拟 CSS 媒体查询 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import { getOrientation } from './useOrientation'; +import type { MediaQueryOptions } from './types'; + +/** + * 模拟 CSS 媒体查询 + * + * @param query - 查询条件 + * @returns 是否匹配 + * + * @example + * const isMinWidth768 = useMediaQuery({ minWidth: 768 }); + * const isMaxWidth1024 = useMediaQuery({ maxWidth: 1024 }); + * const isPortrait = useMediaQuery({ orientation: 'portrait' }); + */ +export function useMediaQuery(query: MediaQueryOptions): boolean { + const { width, height } = useWindowDimensions(); + const currentOrientation = getOrientation(width, height); + + return useMemo(() => { + if (query.minWidth !== undefined && width < query.minWidth) return false; + if (query.maxWidth !== undefined && width > query.maxWidth) return false; + if (query.minHeight !== undefined && height < query.minHeight) return false; + if (query.maxHeight !== undefined && height > query.maxHeight) return false; + if (query.orientation !== undefined && currentOrientation !== query.orientation) return false; + return true; + }, [width, height, currentOrientation, query]); +} + +export default useMediaQuery; diff --git a/src/presentation/hooks/responsive/useOrientation.ts b/src/presentation/hooks/responsive/useOrientation.ts new file mode 100644 index 0000000..7994961 --- /dev/null +++ b/src/presentation/hooks/responsive/useOrientation.ts @@ -0,0 +1,43 @@ +/** + * useOrientation Hook + * 方向检测 - 检测设备方向 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import type { Orientation } from './types'; + +/** + * 获取屏幕方向 + */ +export function getOrientation(width: number, height: number): Orientation { + return width > height ? 'landscape' : 'portrait'; +} + +/** + * 方向检测 Hook + * 提供便捷的方向检测方法 + * + * @returns 方向信息对象 + * + * @example + * const { orientation, isPortrait, isLandscape } = useOrientation(); + */ +export function useOrientation(): { + orientation: Orientation; + isPortrait: boolean; + isLandscape: boolean; +} { + const { width, height } = useWindowDimensions(); + + return useMemo(() => { + const orientation = getOrientation(width, height); + return { + orientation, + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + }; + }, [width, height]); +} + +export default useOrientation; diff --git a/src/presentation/hooks/responsive/usePlatform.ts b/src/presentation/hooks/responsive/usePlatform.ts new file mode 100644 index 0000000..b54952d --- /dev/null +++ b/src/presentation/hooks/responsive/usePlatform.ts @@ -0,0 +1,29 @@ +/** + * usePlatform Hook + * 平台检测 - 检测当前运行平台 + */ + +import { useMemo } from 'react'; +import { Platform } from 'react-native'; +import type { PlatformInfo } from './types'; + +/** + * 平台检测 Hook + * 提供便捷的平台检测方法 + * + * @returns 平台信息对象 + * + * @example + * const { isWeb, isIOS, isAndroid, isNative } = usePlatform(); + */ +export function usePlatform(): PlatformInfo { + return useMemo(() => ({ + OS: Platform.OS as PlatformInfo['OS'], + isWeb: Platform.OS === 'web', + isIOS: Platform.OS === 'ios', + isAndroid: Platform.OS === 'android', + isNative: Platform.OS !== 'web', + }), []); +} + +export default usePlatform; diff --git a/src/presentation/hooks/responsive/useResponsive.ts b/src/presentation/hooks/responsive/useResponsive.ts new file mode 100644 index 0000000..168805b --- /dev/null +++ b/src/presentation/hooks/responsive/useResponsive.ts @@ -0,0 +1,112 @@ +/** + * useResponsive Hook (兼容层) + * + * 此文件是为了保持向后兼容性而保留的,整合所有响应式功能。 + * + * 推荐使用新的专注 hooks: + * - useBreakpoint() - 断点检测 + * - useScreenSize() - 屏幕尺寸 + * - useOrientation() - 方向检测 + * - usePlatform() - 平台检测 + * - useResponsiveValue() - 响应式值 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import { getBreakpoint, getFineBreakpoint } from './useBreakpoint'; +import { getOrientation } from './useOrientation'; +import { usePlatform } from './usePlatform'; +import type { BreakpointKey, FineBreakpointKey, Orientation, PlatformInfo } from './types'; + +// ==================== 返回值类型 ==================== +export interface ResponsiveInfo { + // 基础尺寸 + width: number; + height: number; + + // 基础断点 + breakpoint: BreakpointKey; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; + isWideScreen: boolean; + + // 细粒度断点 + fineBreakpoint: FineBreakpointKey; + isXS: boolean; + isSM: boolean; + isMD: boolean; + isLG: boolean; + isXL: boolean; + is2XL: boolean; + is3XL: boolean; + is4XL: boolean; + + // 方向 + orientation: Orientation; + isPortrait: boolean; + isLandscape: boolean; + + // 平台检测 + platform: PlatformInfo; +} + +/** + * 响应式设计 Hook (兼容层) + * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 + * + * @deprecated 推荐使用新的专注 hooks + * + * @returns ResponsiveInfo 响应式信息对象 + * + * @example + * const { + * width, height, + * breakpoint, isMobile, isTablet, isDesktop, isWide, + * fineBreakpoint, isXS, isSM, isMD, isLG, isXL, + * orientation, isPortrait, isLandscape, + * platform: { isWeb, isIOS, isAndroid } + * } = useResponsive(); + */ +export function useResponsive(): ResponsiveInfo { + const { width, height } = useWindowDimensions(); + const platform = usePlatform(); + + return useMemo(() => { + const breakpoint = getBreakpoint(width); + const fineBreakpoint = getFineBreakpoint(width); + const orientation = getOrientation(width, height); + + return { + width, + height, + breakpoint, + isMobile: breakpoint === 'mobile', + isTablet: breakpoint === 'tablet', + isDesktop: breakpoint === 'desktop', + isWide: breakpoint === 'wide', + isWideScreen: breakpoint === 'tablet' || breakpoint === 'desktop' || breakpoint === 'wide', + fineBreakpoint, + isXS: fineBreakpoint === 'xs', + isSM: fineBreakpoint === 'sm', + isMD: fineBreakpoint === 'md', + isLG: fineBreakpoint === 'lg', + isXL: fineBreakpoint === 'xl', + is2XL: fineBreakpoint === '2xl', + is3XL: fineBreakpoint === '3xl', + is4XL: fineBreakpoint === '4xl', + orientation, + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + platform, + }; + }, [width, height, platform]); +} + +/** + * 旧的 useResponsive 别名,保持完全向后兼容 + */ +export const useLegacyResponsive = useResponsive; + +export default useResponsive; diff --git a/src/presentation/hooks/responsive/useResponsiveSpacing.ts b/src/presentation/hooks/responsive/useResponsiveSpacing.ts new file mode 100644 index 0000000..75debc5 --- /dev/null +++ b/src/presentation/hooks/responsive/useResponsiveSpacing.ts @@ -0,0 +1,54 @@ +/** + * useResponsiveSpacing Hook + * 响应式间距 - 根据断点返回对应的间距值 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import { getFineBreakpoint } from './useBreakpoint'; +import type { FineBreakpointKey } from './types'; + +const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + +const defaultConfig: Record = { + xs: 8, + sm: 8, + md: 12, + lg: 16, + xl: 20, + '2xl': 24, + '3xl': 32, + '4xl': 40, +}; + +/** + * 响应式间距 Hook + * + * @param spacingConfig - 间距配置 + * @returns 当前断点对应的间距值 + * + * @example + * const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); + */ +export function useResponsiveSpacing( + spacingConfig: Partial> = {} +): number { + const { width } = useWindowDimensions(); + const fineBreakpoint = getFineBreakpoint(width); + + return useMemo(() => { + const config = { ...defaultConfig, ...spacingConfig }; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (config[bp] !== undefined) { + return config[bp]; + } + } + + return defaultConfig.xs; + }, [fineBreakpoint, spacingConfig]); +} + +export default useResponsiveSpacing; diff --git a/src/presentation/hooks/responsive/useResponsiveValue.ts b/src/presentation/hooks/responsive/useResponsiveValue.ts new file mode 100644 index 0000000..8d24dd9 --- /dev/null +++ b/src/presentation/hooks/responsive/useResponsiveValue.ts @@ -0,0 +1,95 @@ +/** + * useResponsiveValue Hook + * 响应式值映射 - 根据当前断点返回对应值 + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; +import { getFineBreakpoint } from './useBreakpoint'; +import type { FineBreakpointKey, ResponsiveValue } from './types'; + +const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + +/** + * 根据当前断点从响应式值对象中选择合适的值 + * + * @param value - 响应式值,可以是单一值或断点映射对象 + * @returns 选中的值 + * + * @example + * const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); + * // 在 xs 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 24 + */ +export function useResponsiveValue(value: ResponsiveValue): T { + const { width } = useWindowDimensions(); + const fineBreakpoint = getFineBreakpoint(width); + + return useMemo(() => { + // 如果不是对象,直接返回 + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value as T; + } + + const valueMap = value as Partial>; + + // 从当前断点开始向下查找 + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (bp in valueMap) { + return valueMap[bp]!; + } + } + + // 如果没找到,返回 xs 的值或第一个值 + return (valueMap.xs ?? Object.values(valueMap)[0]) as T; + }, [value, fineBreakpoint]); +} + +/** + * 响应式样式生成器 + * 根据断点生成响应式样式 + * + * @param styles - 响应式样式对象 + * @returns 当前断点对应的样式 + * + * @example + * const containerStyle = useResponsiveStyle({ + * padding: { xs: 8, md: 16, lg: 24 }, + * fontSize: { xs: 14, lg: 16 } + * }); + */ +export function useResponsiveStyle>>( + styles: T +): { [K in keyof T]: T[K] extends ResponsiveValue ? V : never } { + const { width } = useWindowDimensions(); + const fineBreakpoint = getFineBreakpoint(width); + + return useMemo(() => { + const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue ? V : never }; + + for (const key in styles) { + const value = styles[key]; + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + (result as Record)[key] = value; + } else { + const valueMap = value as Partial>; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + let selectedValue: unknown = undefined; + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (bp in valueMap) { + selectedValue = valueMap[bp]; + break; + } + } + + (result as Record)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0]; + } + } + + return result; + }, [styles, fineBreakpoint]); +} diff --git a/src/presentation/hooks/responsive/useScreenSize.ts b/src/presentation/hooks/responsive/useScreenSize.ts new file mode 100644 index 0000000..2d71df1 --- /dev/null +++ b/src/presentation/hooks/responsive/useScreenSize.ts @@ -0,0 +1,63 @@ +/** + * useScreenSize Hook + * 屏幕尺寸检测 - 提供屏幕尺寸和断点信息 + */ + +import { useState, useEffect, useMemo } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; +import { getBreakpoint, getFineBreakpoint } from './useBreakpoint'; +import type { BreakpointKey, FineBreakpointKey, ScreenSize } from './types'; + +/** + * 获取窗口尺寸,支持屏幕旋转响应 + * 替代 Dimensions.get('window'),提供实时尺寸更新 + */ +export function useWindowDimensions(): ScaledSize { + const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setDimensions(window); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return dimensions; +} + +/** + * 屏幕尺寸 Hook + * 提供屏幕尺寸信息和断点状态 + * + * @returns 屏幕尺寸信息 + * + * @example + * const { width, height, isMobile, isTablet, isDesktop, isWide } = useScreenSize(); + */ +export function useScreenSize(): ScreenSize & { + breakpoint: BreakpointKey; + fineBreakpoint: FineBreakpointKey; +} { + const { width, height } = useWindowDimensions(); + + return useMemo(() => { + const breakpoint = getBreakpoint(width); + const fineBreakpoint = getFineBreakpoint(width); + + return { + width, + height, + breakpoint, + fineBreakpoint, + isMobile: breakpoint === 'mobile', + isTablet: breakpoint === 'tablet', + isDesktop: breakpoint === 'desktop', + isWide: breakpoint === 'wide', + }; + }, [width, height]); +} + +export default useScreenSize; diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts new file mode 100644 index 0000000..e89ba73 --- /dev/null +++ b/src/stores/message/MessageManager.ts @@ -0,0 +1,251 @@ +/** + * MessageManager - 重构版 + * 作为协调者,整合各个专注的服务模块 + */ + +import { messageStateManager, MessageStateManager } from './message/MessageStateManager'; +import { webSocketMessageHandler, WebSocketMessageHandler } from './message/WebSocketMessageHandler'; +import { messageSyncService, MessageSyncService } from './message/MessageSyncService'; +import { readReceiptManager, ReadReceiptManager } from './message/ReadReceiptManager'; +import { messageRepository } from '../data/repositories/MessageRepository'; +import { useAuthStore } from './authStore'; +import type { Message, Conversation } from '../core/entities/Message'; +import type { ConversationResponse, MessageResponse } from '../types/dto'; + +export type { + MessageEventType, + MessageEvent, + MessageSubscriber, +} from './message/MessageStateManager'; + +export class MessageManager { + private stateManager: MessageStateManager; + private wsHandler: WebSocketMessageHandler; + private syncService: MessageSyncService; + private readManager: ReadReceiptManager; + + private initialized: boolean = false; + private authUnsubscribe: (() => void) | null = null; + private wsUnsubscribe: (() => void) | null = null; + private currentUserId: string | null = null; + + constructor() { + this.stateManager = messageStateManager; + this.wsHandler = webSocketMessageHandler; + this.syncService = messageSyncService; + this.readManager = readReceiptManager; + + this.readManager.setStateManager(this.stateManager); + this.setupWebSocketHandlers(); + this.initAuthListener(); + } + + private initAuthListener(): void { + const authState = useAuthStore.getState(); + this.currentUserId = authState.currentUser?.id || null; + + if (this.currentUserId && !this.initialized) { + this.initialize(); + } + + this.authUnsubscribe = useAuthStore.subscribe((state) => { + const nextUserId = state.currentUser?.id || null; + this.currentUserId = nextUserId; + + if (nextUserId && !this.initialized) { + this.initialize(); + } + }); + } + + private setupWebSocketHandlers(): void { + this.wsUnsubscribe = this.wsHandler.subscribe((event) => { + switch (event.type) { + case 'chat_message': + case 'group_message': + this.handleNewMessage(event.payload); + break; + case 'read_receipt': + case 'group_read_receipt': + this.handleReadReceipt(event.payload); + break; + case 'message_recalled': + case 'group_message_recalled': + this.handleMessageRecall(event.payload); + break; + case 'typing': + this.handleTypingStatus(event.payload); + break; + case 'group_notice': + this.handleGroupNotice(event.payload); + break; + } + }); + } + + async initialize(): Promise { + if (this.initialized) return; + + try { + console.log('[MessageManager] Initializing...'); + + this.wsHandler.connect(); + + const conversations = await this.syncService.syncConversations(); + this.stateManager.setConversations(conversations); + + this.initialized = true; + console.log('[MessageManager] Initialized successfully'); + } catch (error) { + console.error('[MessageManager] Initialization failed:', error); + throw error; + } + } + + async activateConversation(conversationId: string): Promise { + this.stateManager.setCurrentConversation(conversationId); + + const existing = this.stateManager.getMessages(conversationId); + if (existing.length === 0) { + await this.loadMessages(conversationId); + } + + const conversation = this.stateManager.getConversation(conversationId); + if (conversation && conversation.unreadCount > 0) { + await this.readManager.markAsRead(conversationId, conversation.lastSeq); + } + } + + deactivateConversation(): void { + this.stateManager.setCurrentConversation(null); + } + + async loadMessages(conversationId: string): Promise { + const localMessages = await this.syncService.loadLocalMessages(conversationId); + + if (localMessages.length > 0) { + this.stateManager.setMessages(conversationId, localMessages); + } + + const syncResult = await this.syncService.syncMessages(conversationId); + + if (syncResult.messages.length > 0) { + this.stateManager.appendMessages(conversationId, syncResult.messages); + return [...localMessages, ...syncResult.messages]; + } + + return localMessages; + } + + async loadHistory(conversationId: string): Promise { + const messages = this.stateManager.getMessages(conversationId); + const oldestSeq = messages.length > 0 ? Math.min(...messages.map(m => m.seq)) : 0; + + const history = await this.syncService.loadHistoryMessages( + conversationId, + oldestSeq, + 20 + ); + + if (history.length > 0) { + this.stateManager.prependMessages(conversationId, history); + } + + return history; + } + + async sendMessage( + conversationId: string, + content: string, + type: 'private' | 'group' = 'private' + ): Promise { + throw new Error('sendMessage not implemented - use existing implementation'); + } + + private async handleNewMessage(message: Message): Promise { + await messageRepository.saveMessage(message, true); + + this.stateManager.addMessage(message.conversationId, message); + + const currentConvId = this.stateManager.getCurrentConversationId(); + if (currentConvId !== message.conversationId) { + const conv = this.stateManager.getConversation(message.conversationId); + if (conv) { + this.stateManager.updateConversation(message.conversationId, { + unreadCount: (conv.unreadCount || 0) + 1, + lastSeq: message.seq, + lastMessageAt: message.createdAt, + }); + } + } + } + + private handleReadReceipt(payload: any): void { + const { conversationId, lastReadSeq } = payload; + + this.stateManager.updateConversation(conversationId, { + unreadCount: 0, + }); + } + + private async handleMessageRecall(payload: any): Promise { + const { messageId, conversationId } = payload; + + await messageRepository.updateMessageStatus(messageId, 'recalled', true); + + this.stateManager.updateMessage(conversationId, messageId, { + status: 'recalled', + }); + } + + private handleTypingStatus(payload: any): void { + const { groupId, userIds } = payload; + this.stateManager.setTypingUsers(groupId, userIds); + } + + private handleGroupNotice(notice: any): void { + console.log('[MessageManager] Group notice:', notice); + } + + subscribe(callback: any): () => void { + return this.stateManager.subscribe(callback); + } + + getConversations(): Conversation[] { + return this.stateManager.getConversations(); + } + + getMessages(conversationId: string): Message[] { + return this.stateManager.getMessages(conversationId); + } + + getUnreadCount(): { total: number; system: number } { + return this.stateManager.getUnreadCount(); + } + + isConnected(): boolean { + return this.wsHandler.isConnected(); + } + + reset(): void { + this.initialized = false; + this.stateManager.reset(); + this.readManager.reset(); + this.wsHandler.disconnect(); + } + + destroy(): void { + if (this.authUnsubscribe) { + this.authUnsubscribe(); + this.authUnsubscribe = null; + } + if (this.wsUnsubscribe) { + this.wsUnsubscribe(); + this.wsUnsubscribe = null; + } + this.reset(); + } +} + +export const messageManager = new MessageManager(); +export default messageManager; \ No newline at end of file diff --git a/src/stores/message/MessageStateManager.ts b/src/stores/message/MessageStateManager.ts new file mode 100644 index 0000000..80b2529 --- /dev/null +++ b/src/stores/message/MessageStateManager.ts @@ -0,0 +1,301 @@ +/** + * 消息状态管理器 + * 只负责管理状态,不包含业务逻辑 + */ + +import type { Message, Conversation, UnreadCount } from '../../core/entities/Message'; + +export type MessageEventType = + | 'conversations_updated' + | 'messages_updated' + | 'unread_count_updated' + | 'connection_changed' + | 'message_sent' + | 'message_read' + | 'message_received' + | 'message_recalled' + | 'typing_status' + | 'group_notice' + | 'error'; + +export interface MessageEvent { + type: MessageEventType; + payload: any; + timestamp: number; +} + +export type MessageSubscriber = (event: MessageEvent) => void; + +export interface MessageState { + conversations: Map; + conversationList: Conversation[]; + messagesMap: Map; + unreadCount: UnreadCount; + isWebSocketConnected: boolean; + currentConversationId: string | null; + isLoading: boolean; + typingUsersMap: Map; + mutedStatusMap: Map; +} + +export class MessageStateManager { + private state: MessageState; + private subscribers: Set; + private readStateVersion: number = 0; + private pendingReadMap: Map = new Map(); + + constructor() { + this.state = { + conversations: new Map(), + conversationList: [], + messagesMap: new Map(), + unreadCount: { total: 0, system: 0 }, + isWebSocketConnected: false, + currentConversationId: null, + isLoading: false, + typingUsersMap: new Map(), + mutedStatusMap: new Map(), + }; + this.subscribers = new Set(); + } + + // ==================== 状态获取 ==================== + + getState(): MessageState { + return this.state; + } + + getConversations(): Conversation[] { + return this.state.conversationList; + } + + getConversation(id: string): Conversation | undefined { + return this.state.conversations.get(id); + } + + getMessages(conversationId: string): Message[] { + return this.state.messagesMap.get(conversationId) || []; + } + + getUnreadCount(): UnreadCount { + return this.state.unreadCount; + } + + getCurrentConversationId(): string | null { + return this.state.currentConversationId; + } + + isConnected(): boolean { + return this.state.isWebSocketConnected; + } + + getTypingUsers(groupId: string): string[] { + return this.state.typingUsersMap.get(groupId) || []; + } + + getMutedStatus(groupId: string): boolean { + return this.state.mutedStatusMap.get(groupId) || false; + } + + // ==================== 状态更新 ==================== + + setConversations(conversations: Conversation[]): void { + this.state.conversations.clear(); + this.state.conversationList = conversations; + + for (const conv of conversations) { + this.state.conversations.set(conv.id, conv); + } + + this.updateUnreadCount(); + this.notify({ type: 'conversations_updated', payload: conversations }); + } + + addConversation(conversation: Conversation): void { + this.state.conversations.set(conversation.id, conversation); + this.state.conversationList = Array.from(this.state.conversations.values()); + this.updateUnreadCount(); + this.notify({ type: 'conversations_updated', payload: this.state.conversationList }); + } + + updateConversation(id: string, updates: Partial): void { + const existing = this.state.conversations.get(id); + if (existing) { + const updated = { ...existing, ...updates }; + this.state.conversations.set(id, updated); + this.state.conversationList = Array.from(this.state.conversations.values()); + this.updateUnreadCount(); + this.notify({ type: 'conversations_updated', payload: this.state.conversationList }); + } + } + + removeConversation(id: string): void { + this.state.conversations.delete(id); + this.state.messagesMap.delete(id); + this.state.conversationList = Array.from(this.state.conversations.values()); + this.updateUnreadCount(); + this.notify({ type: 'conversations_updated', payload: this.state.conversationList }); + } + + setMessages(conversationId: string, messages: Message[]): void { + this.state.messagesMap.set(conversationId, messages); + this.notify({ type: 'messages_updated', payload: { conversationId, messages } }); + } + + addMessage(conversationId: string, message: Message): void { + const messages = this.state.messagesMap.get(conversationId) || []; + const existingIndex = messages.findIndex(m => m.id === message.id); + + if (existingIndex >= 0) { + messages[existingIndex] = message; + } else { + messages.push(message); + } + + this.state.messagesMap.set(conversationId, messages); + this.notify({ type: 'messages_updated', payload: { conversationId, messages } }); + } + + prependMessages(conversationId: string, newMessages: Message[]): void { + const existing = this.state.messagesMap.get(conversationId) || []; + const merged = [...newMessages, ...existing]; + const unique = this.deduplicateMessages(merged); + this.state.messagesMap.set(conversationId, unique); + this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } }); + } + + appendMessages(conversationId: string, newMessages: Message[]): void { + const existing = this.state.messagesMap.get(conversationId) || []; + const merged = [...existing, ...newMessages]; + const unique = this.deduplicateMessages(merged); + this.state.messagesMap.set(conversationId, unique); + this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } }); + } + + updateMessage(conversationId: string, messageId: string, updates: Partial): void { + const messages = this.state.messagesMap.get(conversationId); + if (!messages) return; + + const index = messages.findIndex(m => m.id === messageId); + if (index >= 0) { + messages[index] = { ...messages[index], ...updates }; + this.notify({ type: 'messages_updated', payload: { conversationId, messages } }); + } + } + + setUnreadCount(count: UnreadCount): void { + this.state.unreadCount = count; + this.notify({ type: 'unread_count_updated', payload: count }); + } + + setWebSocketConnected(connected: boolean): void { + this.state.isWebSocketConnected = connected; + this.notify({ type: 'connection_changed', payload: connected }); + } + + setCurrentConversation(id: string | null): void { + this.state.currentConversationId = id; + } + + setLoading(loading: boolean): void { + this.state.isLoading = loading; + } + + setTypingUsers(groupId: string, userIds: string[]): void { + this.state.typingUsersMap.set(groupId, userIds); + this.notify({ type: 'typing_status', payload: { groupId, userIds } }); + } + + setMutedStatus(groupId: string, muted: boolean): void { + this.state.mutedStatusMap.set(groupId, muted); + } + + // ==================== 已读状态保护 ==================== + + beginReadOperation(conversationId: string): number { + this.readStateVersion++; + this.pendingReadMap.set(conversationId, { + timestamp: Date.now(), + version: this.readStateVersion, + }); + return this.readStateVersion; + } + + endReadOperation(conversationId: string): void { + setTimeout(() => { + this.pendingReadMap.delete(conversationId); + }, 5000); + } + + isReadOperationPending(conversationId: string): boolean { + const record = this.pendingReadMap.get(conversationId); + if (!record) return false; + return Date.now() - record.timestamp < 5000; + } + + getReadStateVersion(): number { + return this.readStateVersion; + } + + // ==================== 订阅机制 ==================== + + subscribe(callback: MessageSubscriber): () => void { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + } + + private notify(event: Omit): void { + const fullEvent: MessageEvent = { + ...event, + timestamp: Date.now(), + }; + this.subscribers.forEach(callback => { + try { + callback(fullEvent); + } catch (error) { + console.error('[MessageStateManager] Subscriber error:', error); + } + }); + } + + // ==================== 工具方法 ==================== + + private updateUnreadCount(): void { + let total = 0; + let system = 0; + + for (const conv of this.state.conversations.values()) { + total += conv.unreadCount || 0; + } + + this.state.unreadCount = { total, system }; + this.notify({ type: 'unread_count_updated', payload: this.state.unreadCount }); + } + + private deduplicateMessages(messages: Message[]): Message[] { + const seen = new Set(); + return messages.filter(msg => { + if (seen.has(msg.id)) return false; + seen.add(msg.id); + return true; + }); + } + + reset(): void { + this.state = { + conversations: new Map(), + conversationList: [], + messagesMap: new Map(), + unreadCount: { total: 0, system: 0 }, + isWebSocketConnected: false, + currentConversationId: null, + isLoading: false, + typingUsersMap: new Map(), + mutedStatusMap: new Map(), + }; + this.pendingReadMap.clear(); + this.readStateVersion = 0; + } +} + +export const messageStateManager = new MessageStateManager(); \ No newline at end of file diff --git a/src/stores/message/MessageSyncService.ts b/src/stores/message/MessageSyncService.ts new file mode 100644 index 0000000..85b43bb --- /dev/null +++ b/src/stores/message/MessageSyncService.ts @@ -0,0 +1,162 @@ +/** + * 消息同步服务 + * 负责从服务器同步消息和会话 + */ + +import { messageService } from '../../services/messageService'; +import { messageRepository } from '../../data/repositories/MessageRepository'; +import type { Message, Conversation } from '../../core/entities/Message'; +import type { ConversationResponse, MessageResponse } from '../../types/dto'; + +export interface SyncOptions { + force?: boolean; + lastSeq?: number; +} + +export interface SyncResult { + conversations: Conversation[]; + messages: Message[]; + hasMore: boolean; +} + +export class MessageSyncService { + private syncingConversations: boolean = false; + private syncingMessages: Map = new Map(); + + async syncConversations(): Promise { + if (this.syncingConversations) { + console.log('[MessageSyncService] Already syncing conversations'); + return []; + } + + this.syncingConversations = true; + + try { + const response = await messageService.getConversations(); + const conversations = this.mapConversations(response); + return conversations; + } catch (error) { + console.error('[MessageSyncService] Failed to sync conversations:', error); + throw error; + } finally { + this.syncingConversations = false; + } + } + + async syncMessages( + conversationId: string, + options: SyncOptions = {} + ): Promise { + const key = conversationId; + + if (this.syncingMessages.get(key)) { + console.log('[MessageSyncService] Already syncing messages for:', conversationId); + return { conversations: [], messages: [], hasMore: false }; + } + + this.syncingMessages.set(key, true); + + try { + const lastSeq = options.lastSeq ?? await messageRepository.getMaxSeq(conversationId); + + const response = await messageService.getMessages(conversationId, { + after_seq: lastSeq, + limit: 50, + }); + + const messages = this.mapMessages(response.messages || []); + + if (messages.length > 0) { + await messageRepository.saveMessages(messages, true); + } + + return { + conversations: [], + messages, + hasMore: response.has_more || false, + }; + } catch (error) { + console.error('[MessageSyncService] Failed to sync messages:', error); + throw error; + } finally { + this.syncingMessages.delete(key); + } + } + + async loadHistoryMessages( + conversationId: string, + beforeSeq: number, + limit: number = 20 + ): Promise { + try { + const response = await messageService.getMessages(conversationId, { + before_seq: beforeSeq, + limit, + }); + + const messages = this.mapMessages(response.messages || []); + + if (messages.length > 0) { + await messageRepository.saveMessages(messages, true); + } + + return messages; + } catch (error) { + console.error('[MessageSyncService] Failed to load history:', error); + throw error; + } + } + + async loadLocalMessages( + conversationId: string, + limit: number = 50 + ): Promise { + return messageRepository.getMessagesByConversation(conversationId, limit); + } + + async loadHistoryFromLocal( + conversationId: string, + beforeSeq: number, + limit: number = 20 + ): Promise { + return messageRepository.getMessagesBeforeSeq(conversationId, beforeSeq, limit); + } + + private mapConversations(response: ConversationResponse[]): Conversation[] { + return response.map(conv => ({ + id: conv.id, + type: conv.type || 'private', + isPinned: conv.isPinned || false, + lastSeq: conv.lastSeq || conv.last_seq || 0, + lastMessageAt: conv.lastMessageAt || conv.last_message_at || new Date().toISOString(), + unreadCount: conv.unreadCount || conv.unread_count || 0, + participants: conv.participants || [], + group: conv.group, + createdAt: conv.createdAt || conv.created_at || new Date().toISOString(), + updatedAt: conv.updatedAt || conv.updated_at || new Date().toISOString(), + })); + } + + private mapMessages(response: MessageResponse[]): Message[] { + return response.map(msg => ({ + id: msg.id, + conversationId: msg.conversationId || msg.conversation_id || '', + senderId: msg.senderId || msg.sender_id || '', + seq: msg.seq || 0, + segments: msg.segments || [], + createdAt: msg.createdAt || msg.created_at || new Date().toISOString(), + status: msg.status || 'normal', + sender: msg.sender, + })); + } + + isSyncingConversations(): boolean { + return this.syncingConversations; + } + + isSyncingMessages(conversationId: string): boolean { + return this.syncingMessages.get(conversationId) || false; + } +} + +export const messageSyncService = new MessageSyncService(); \ No newline at end of file diff --git a/src/stores/message/ReadReceiptManager.ts b/src/stores/message/ReadReceiptManager.ts new file mode 100644 index 0000000..babac6f --- /dev/null +++ b/src/stores/message/ReadReceiptManager.ts @@ -0,0 +1,92 @@ +/** + * 已读回执管理器 + * 负责处理消息已读状态的同步 + */ + +import { messageService } from '../../services/messageService'; +import { messageRepository } from '../../data/repositories/MessageRepository'; +import type { MessageStateManager } from './MessageStateManager'; + +export interface ReadReceiptResult { + conversationId: string; + lastReadSeq: number; + success: boolean; +} + +export class ReadReceiptManager { + private pendingOperations: Map> = new Map(); + private stateManager: MessageStateManager | null = null; + + setStateManager(manager: MessageStateManager): void { + this.stateManager = manager; + } + + async markAsRead( + conversationId: string, + lastSeq: number + ): Promise { + const existing = this.pendingOperations.get(conversationId); + if (existing) { + await existing; + } + + if (!this.stateManager) { + throw new Error('StateManager not set'); + } + + const version = this.stateManager.beginReadOperation(conversationId); + + const promise = this.executeMarkAsRead(conversationId, lastSeq, version); + this.pendingOperations.set(conversationId, promise); + + try { + await promise; + return { conversationId, lastReadSeq: lastSeq, success: true }; + } catch (error) { + console.error('[ReadReceiptManager] Failed to mark as read:', error); + return { conversationId, lastReadSeq: lastSeq, success: false }; + } finally { + this.pendingOperations.delete(conversationId); + this.stateManager.endReadOperation(conversationId); + } + } + + private async executeMarkAsRead( + conversationId: string, + lastSeq: number, + version: number + ): Promise { + await messageService.markAsRead(conversationId, lastSeq); + + await messageRepository.markConversationAsRead(conversationId); + + if (this.stateManager) { + this.stateManager.updateConversation(conversationId, { + unreadCount: 0, + }); + } + } + + async markAllAsRead(): Promise { + if (!this.stateManager) return; + + const conversations = this.stateManager.getConversations(); + const unreadConversations = conversations.filter(c => c.unreadCount > 0); + + await Promise.all( + unreadConversations.map(conv => + this.markAsRead(conv.id, conv.lastSeq) + ) + ); + } + + isPending(conversationId: string): boolean { + return this.pendingOperations.has(conversationId); + } + + reset(): void { + this.pendingOperations.clear(); + } +} + +export const readReceiptManager = new ReadReceiptManager(); \ No newline at end of file diff --git a/src/stores/message/WebSocketMessageHandler.ts b/src/stores/message/WebSocketMessageHandler.ts new file mode 100644 index 0000000..d9ee386 --- /dev/null +++ b/src/stores/message/WebSocketMessageHandler.ts @@ -0,0 +1,136 @@ +/** + * WebSocket 消息处理器 + * 只负责处理 WebSocket 消息,不管理状态 + */ + +import { sseService, WSChatMessage, WSGroupChatMessage, WSReadMessage, WSGroupReadMessage, WSRecallMessage, WSGroupRecallMessage, WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType } from '../../services/sseService'; +import type { Message, GroupNotice } from '../../core/entities/Message'; + +export type WebSocketEventType = + | 'chat_message' + | 'group_message' + | 'read_receipt' + | 'group_read_receipt' + | 'message_recalled' + | 'group_message_recalled' + | 'typing' + | 'group_notice'; + +export interface WebSocketEvent { + type: WebSocketEventType; + payload: any; + raw: any; +} + +export type WebSocketEventHandler = (event: WebSocketEvent) => void; + +export class WebSocketMessageHandler { + private unsubscribe: (() => void) | null = null; + private handlers: Set = new Set(); + + connect(): void { + if (this.unsubscribe) return; + + this.unsubscribe = sseService.subscribe(this.handleSSEEvent.bind(this)); + } + + disconnect(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + this.handlers.clear(); + } + + subscribe(handler: WebSocketEventHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + private handleSSEEvent(event: any): void { + const { type, data } = event; + + switch (type) { + case 'chat': + this.emit('chat_message', this.parseChatMessage(data), data); + break; + case 'group_message': + this.emit('group_message', this.parseGroupMessage(data), data); + break; + case 'read': + this.emit('read_receipt', data, data); + break; + case 'group_read': + this.emit('group_read_receipt', data, data); + break; + case 'recall': + this.emit('message_recalled', data, data); + break; + case 'group_recall': + this.emit('group_message_recalled', data, data); + break; + case 'typing': + this.emit('typing', data, data); + break; + case 'group_notice': + this.emit('group_notice', this.parseGroupNotice(data), data); + break; + default: + console.log('[WebSocketMessageHandler] Unknown event type:', type); + } + } + + private emit(type: WebSocketEventType, payload: any, raw: any): void { + const event: WebSocketEvent = { type, payload, raw }; + this.handlers.forEach(handler => { + try { + handler(event); + } catch (error) { + console.error('[WebSocketMessageHandler] Handler error:', error); + } + }); + } + + private parseChatMessage(data: WSChatMessage): Message { + return { + id: data.id || '', + conversationId: data.conversationId || data.conversation_id || '', + senderId: data.senderId || data.sender_id || '', + seq: data.seq || 0, + segments: data.segments || [], + createdAt: data.createdAt || data.created_at || new Date().toISOString(), + status: 'normal', + sender: data.sender, + }; + } + + private parseGroupMessage(data: WSGroupChatMessage): Message { + return { + id: data.id || '', + conversationId: data.groupId || data.group_id || '', + senderId: data.senderId || data.sender_id || '', + seq: data.seq || 0, + segments: data.segments || [], + createdAt: data.createdAt || data.created_at || new Date().toISOString(), + status: 'normal', + sender: data.sender, + }; + } + + private parseGroupNotice(data: WSGroupNoticeMessage): GroupNotice { + return { + type: data.noticeType || data.notice_type || 'member_join', + groupId: data.groupId || data.group_id || '', + data: data.data || {}, + timestamp: data.timestamp || Date.now(), + messageId: data.messageId || data.message_id, + seq: data.seq, + }; + } + + isConnected(): boolean { + return sseService.isConnected(); + } +} + +export const webSocketMessageHandler = new WebSocketMessageHandler(); \ No newline at end of file diff --git a/src/stores/message/index.ts b/src/stores/message/index.ts new file mode 100644 index 0000000..c13fbcf --- /dev/null +++ b/src/stores/message/index.ts @@ -0,0 +1,19 @@ +/** + * 消息模块统一导出 + */ + +// 导出主要的管理器 +export { messageManager, MessageManager } from './MessageManager'; + +// 导出状态管理器 +export { messageStateManager, MessageStateManager } from './MessageStateManager'; +export type { MessageState, MessageEvent, MessageSubscriber, MessageEventType } from './MessageStateManager'; + +// 导出同步服务 +export { messageSyncService, MessageSyncService } from './MessageSyncService'; + +// 导出WebSocket处理器 +export { webSocketMessageHandler, WebSocketMessageHandler } from './WebSocketMessageHandler'; + +// 导出已读管理器 +export { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager'; \ No newline at end of file diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index c633013..1b06334 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -15,6 +15,7 @@ import { } from '../services'; import { userManager } from './userManager'; import { messageManager } from './messageManager'; +import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate'; interface UserState { // 状态 @@ -59,504 +60,386 @@ interface UserState { refreshAll: () => Promise; } -export const useUserStore = create((set, get) => ({ - // 初始状态 - users: [], - userCache: {}, - posts: [], - notifications: [], - notificationBadge: { - total: 0, - likes: 0, - comments: 0, - follows: 0, - system: 0, - }, - messageUnreadCount: 0, - searchHistory: [], - isLoadingPosts: false, - isLoadingNotifications: false, - - // 获取用户信息 - fetchUser: async (userId: string) => { - // 先检查缓存 - const cached = get().userCache[userId]; - if (cached) return cached; +export const useUserStore = create((set, get) => { + // 创建乐观更新器 + const optimisticUpdater = createOptimisticUpdaterFactory(set, get); + + return { + // 初始状态 + users: [], + userCache: {}, + posts: [], + notifications: [], + notificationBadge: { + total: 0, + likes: 0, + comments: 0, + follows: 0, + system: 0, + }, + messageUnreadCount: 0, + searchHistory: [], + isLoadingPosts: false, + isLoadingNotifications: false, - try { - const user = await userManager.getUserById(userId); - if (user) { + // 获取用户信息 + fetchUser: async (userId: string) => { + // 先检查缓存 + const cached = get().userCache[userId]; + if (cached) return cached; + + try { + const user = await userManager.getUserById(userId); + if (user) { + set(state => ({ + userCache: { ...state.userCache, [userId]: user } + })); + return user; + } + } catch (error) { + console.error('获取用户信息失败:', error); + } + return undefined; + }, + + // 获取用户帖子 + fetchUserPosts: async (userId: string, page = 1) => { + try { + const response = await postService.getUserPosts(userId, page); + const newPosts = response.list; + set(state => ({ - userCache: { ...state.userCache, [userId]: user } + posts: page === 1 ? newPosts : [...state.posts, ...newPosts] })); - return user; + + return newPosts; + } catch (error) { + console.error('获取用户帖子失败:', error); + return []; } - } catch (error) { - console.error('获取用户信息失败:', error); - } - return undefined; - }, - - // 获取用户帖子 - fetchUserPosts: async (userId: string, page = 1) => { - try { - const response = await postService.getUserPosts(userId, page); - const newPosts = response.list; - - set(state => ({ - posts: page === 1 ? newPosts : [...state.posts, ...newPosts] - })); - - return newPosts; - } catch (error) { - console.error('获取用户帖子失败:', error); - return []; - } - }, - - // 获取帖子列表(首页) - fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => { - set({ isLoadingPosts: true }); + }, - try { - let response; + // 获取帖子列表(首页) + fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => { + set({ isLoadingPosts: true }); - switch (type) { - case 'recommend': - response = await postService.getRecommendedPosts(page); - break; - case 'follow': - response = await postService.getFollowingPosts(page); - break; - case 'hot': - response = await postService.getHotPosts(page); - break; - case 'latest': - default: - response = await postService.getLatestPosts(page); - break; + try { + let response; + + switch (type) { + case 'recommend': + response = await postService.getRecommendedPosts(page); + break; + case 'follow': + response = await postService.getFollowingPosts(page); + break; + case 'hot': + response = await postService.getHotPosts(page); + break; + case 'latest': + default: + response = await postService.getLatestPosts(page); + break; + } + + const newPosts = response.list; + + set(state => ({ + posts: page === 1 ? newPosts : [...state.posts, ...newPosts], + isLoadingPosts: false + })); + + return response; + } catch (error) { + console.error('获取帖子列表失败:', error); + set({ isLoadingPosts: false }); + return { + list: [], + total: 0, + page, + page_size: 20, + total_pages: 0, + }; } - - const newPosts = response.list; - - set(state => ({ - posts: page === 1 ? newPosts : [...state.posts, ...newPosts], - isLoadingPosts: false - })); - - return response; - } catch (error) { - console.error('获取帖子列表失败:', error); - set({ isLoadingPosts: false }); - return { - list: [], - total: 0, - page, - page_size: 20, - total_pages: 0, - }; - } - }, - - // 获取通知列表 - fetchNotifications: async (type?: string, page = 1) => { - set({ isLoadingNotifications: true }); + }, - try { - const response = await notificationService.getNotifications( - page, - 20, - type as any - ); + // 获取通知列表 + fetchNotifications: async (type?: string, page = 1) => { + set({ isLoadingNotifications: true }); - const newNotifications = response.list; - - set(state => ({ - notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications], - isLoadingNotifications: false - })); - - return newNotifications; - } catch (error) { - console.error('获取通知列表失败:', error); - set({ isLoadingNotifications: false }); - return []; - } - }, - - // 标记通知为已读 - markNotificationAsRead: async (notificationId: string) => { - try { - await notificationService.markAsRead(notificationId); - - set(state => { - const notifications = state.notifications.map(n => - n.id === notificationId ? { ...n, isRead: true } : n + try { + const response = await notificationService.getNotifications( + page, + 20, + type as any ); - return { - notifications, + const newNotifications = response.list; + + set(state => ({ + notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications], + isLoadingNotifications: false + })); + + return newNotifications; + } catch (error) { + console.error('获取通知列表失败:', error); + set({ isLoadingNotifications: false }); + return []; + } + }, + + // 标记通知为已读 + markNotificationAsRead: async (notificationId: string) => { + try { + await notificationService.markAsRead(notificationId); + + set(state => { + const notifications = state.notifications.map(n => + n.id === notificationId ? { ...n, isRead: true } : n + ); + + return { + notifications, + notificationBadge: { + total: notifications.filter(n => !n.isRead).length, + likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length, + comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length, + follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length, + system: notifications.filter(n => n.type === 'system' && !n.isRead).length, + } + }; + }); + } catch (error) { + console.error('标记通知为已读失败:', error); + } + }, + + // 标记所有通知为已读 + markAllNotificationsAsRead: async () => { + try { + await notificationService.markAllAsRead(); + + set(state => ({ + notifications: state.notifications.map(n => ({ ...n, isRead: true })), notificationBadge: { - total: notifications.filter(n => !n.isRead).length, - likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length, - comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length, - follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length, - system: notifications.filter(n => n.type === 'system' && !n.isRead).length, + total: 0, + likes: 0, + comments: 0, + follows: 0, + system: 0, } - }; + })); + } catch (error) { + console.error('标记所有通知为已读失败:', error); + } + }, + + // 获取通知角标 + fetchNotificationBadge: async () => { + try { + const badge = await notificationService.getNotificationBadge(); + set({ notificationBadge: badge }); + } catch (error) { + console.error('获取通知角标失败:', error); + } + }, + + // 设置消息未读数 + // 注意:未读数现在由 MessageManager 统一管理 + // 此方法保留用于 TabBar 角标显示,从 MessageManager 同步 + setMessageUnreadCount: (count: number) => { + set({ messageUnreadCount: count }); + }, + + // 从后端拉取最新消息未读数 + // @deprecated 请使用 messageManager.fetchUnreadCount() 代替 + // 此方法保留用于向后兼容 + fetchMessageUnreadCount: async () => { + try { + await messageManager.fetchUnreadCount(); + const unread = messageManager.getUnreadCount(); + set({ messageUnreadCount: unread.total + unread.system }); + } catch (error) { + console.error('获取消息未读数失败:', error); + } + }, + + // 添加搜索历史 + addSearchHistory: (keyword: string) => { + set(state => { + const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20); + return { searchHistory: history }; }); - } catch (error) { - console.error('标记通知为已读失败:', error); - } - }, - - // 标记所有通知为已读 - markAllNotificationsAsRead: async () => { - try { - await notificationService.markAllAsRead(); + }, + + // 清除搜索历史 + clearSearchHistory: () => { + set({ searchHistory: [] }); + }, + + // 点赞帖子 - 使用乐观更新工具 + likePost: async (postId: string) => { + const originalPosts = get().posts; - set(state => ({ - notifications: state.notifications.map(n => ({ ...n, isRead: true })), - notificationBadge: { - total: 0, - likes: 0, - comments: 0, - follows: 0, - system: 0, - } - })); - } catch (error) { - console.error('标记所有通知为已读失败:', error); - } - }, - - // 获取通知角标 - fetchNotificationBadge: async () => { - try { - const badge = await notificationService.getNotificationBadge(); - set({ notificationBadge: badge }); - } catch (error) { - console.error('获取通知角标失败:', error); - } - }, - - // 设置消息未读数 - // 注意:未读数现在由 MessageManager 统一管理 - // 此方法保留用于 TabBar 角标显示,从 MessageManager 同步 - setMessageUnreadCount: (count: number) => { - set({ messageUnreadCount: count }); - }, - - // 从后端拉取最新消息未读数 - // @deprecated 请使用 messageManager.fetchUnreadCount() 代替 - // 此方法保留用于向后兼容 - fetchMessageUnreadCount: async () => { - try { - await messageManager.fetchUnreadCount(); - const unread = messageManager.getUnreadCount(); - set({ messageUnreadCount: unread.total + unread.system }); - } catch (error) { - console.error('获取消息未读数失败:', error); - } - }, - - // 添加搜索历史 - addSearchHistory: (keyword: string) => { - set(state => { - const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20); - return { searchHistory: history }; - }); - }, - - // 清除搜索历史 - clearSearchHistory: () => { - set({ searchHistory: [] }); - }, - - // 点赞帖子 - 乐观更新 - likePost: async (postId: string) => { - // 先乐观更新本地状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p - ) - })); - - // 调用API - try { - const updatedPost = await postService.likePost(postId); - if (updatedPost) { - // 使用后端返回的更新后数据更新状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } else { - // API 返回失败,回滚状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p - ) - })); - } - } catch (error) { - console.error('点赞帖子失败:', error); - // 失败回滚状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p - ) - })); - } - }, - - // 取消点赞 - 乐观更新 - unlikePost: async (postId: string) => { - // 先乐观更新本地状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p - ) - })); - - // 调用API - try { - const updatedPost = await postService.unlikePost(postId); - if (updatedPost) { - // 使用后端返回的更新后数据更新状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } else { - // API 返回失败,回滚状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p - ) - })); - } - } catch (error) { - console.error('取消点赞帖子失败:', error); - // 失败回滚状态 + // 乐观更新 set(state => ({ posts: state.posts.map(p => p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p ) })); - } - }, - - // 收藏帖子 - 乐观更新 - favoritePost: async (postId: string) => { - // 先乐观更新本地状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p - ) - })); - - // 调用API - try { - const updatedPost = await postService.favoritePost(postId); - if (updatedPost) { - // 使用后端返回的更新后数据更新状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } else { - // API 返回失败,回滚状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p - ) - })); + + try { + const updatedPost = await postService.likePost(postId); + if (updatedPost) { + // 使用服务器返回的数据更新 + set(state => ({ + posts: state.posts.map(p => + p.id === postId ? updatedPost : p + ) + })); + } + } catch (error) { + console.error('点赞帖子失败:', error); + // 回滚 + set({ posts: originalPosts }); } - } catch (error) { - console.error('收藏帖子失败:', error); - // 失败回滚状态 + }, + + // 取消点赞 - 使用乐观更新工具 + unlikePost: async (postId: string) => { + const originalPosts = get().posts; + + // 乐观更新 set(state => ({ posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p + p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p ) })); - } - }, - - // 取消收藏 - 乐观更新 - unfavoritePost: async (postId: string) => { - // 先乐观更新本地状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p - ) - })); - - // 调用API - try { - const updatedPost = await postService.unfavoritePost(postId); - if (updatedPost) { - // 使用后端返回的更新后数据更新状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } else { - // API 返回失败,回滚状态 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p - ) - })); + + try { + const updatedPost = await postService.unlikePost(postId); + if (updatedPost) { + // 使用服务器返回的数据更新 + set(state => ({ + posts: state.posts.map(p => + p.id === postId ? updatedPost : p + ) + })); + } + } catch (error) { + console.error('取消点赞帖子失败:', error); + // 回滚 + set({ posts: originalPosts }); } - } catch (error) { - console.error('取消收藏帖子失败:', error); - // 失败回滚状态 + }, + + // 收藏帖子 - 使用乐观更新工具 + favoritePost: async (postId: string) => { + const originalPosts = get().posts; + + // 乐观更新 set(state => ({ posts: state.posts.map(p => p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p ) })); - } - }, - - // 点赞评论 - likeComment: async (commentId: string) => { - // 先更新本地状态(更新posts中的帖子的评论点赞状态) - set(state => ({ - posts: state.posts.map(p => ({ - ...p, - top_comment: p.top_comment && p.top_comment.id === commentId - ? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 } - : p.top_comment - })) - })); + + try { + const updatedPost = await postService.favoritePost(postId); + if (updatedPost) { + // 使用服务器返回的数据更新 + set(state => ({ + posts: state.posts.map(p => + p.id === postId ? updatedPost : p + ) + })); + } + } catch (error) { + console.error('收藏帖子失败:', error); + // 回滚 + set({ posts: originalPosts }); + } + }, - // 调用API - try { - await commentService.likeComment(commentId); - } catch (error) { - console.error('点赞评论失败:', error); - // 失败回滚状态 + // 取消收藏 - 使用乐观更新工具 + unfavoritePost: async (postId: string) => { + const originalPosts = get().posts; + + // 乐观更新 set(state => ({ - posts: state.posts.map(p => ({ - ...p, - top_comment: p.top_comment && p.top_comment.id === commentId - ? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) } - : p.top_comment - })) - })); - } - }, - - // 取消点赞评论 - unlikeComment: async (commentId: string) => { - // 先更新本地状态 - set(state => ({ - posts: state.posts.map(p => ({ - ...p, - top_comment: p.top_comment && p.top_comment.id === commentId - ? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) } - : p.top_comment - })) - })); - - // 调用API - try { - await commentService.unlikeComment(commentId); - } catch (error) { - console.error('取消点赞评论失败:', error); - // 失败回滚状态 - set(state => ({ - posts: state.posts.map(p => ({ - ...p, - top_comment: p.top_comment && p.top_comment.id === commentId - ? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 } - : p.top_comment - })) - })); - } - }, - - // 关注用户 - followUser: async (userId: string) => { - // 先更新本地状态 - set(state => ({ - users: state.users.map(u => - u.id === userId - ? { ...u, isFollowing: true, followersCount: u.followers_count + 1 } - : u - ), - userCache: Object.fromEntries( - Object.entries(state.userCache).map(([id, user]) => [ - id, - id === userId - ? { ...user, isFollowing: true, followersCount: user.followers_count + 1 } - : user - ]) - ) - })); - - // 调用API - try { - await authService.followUser(userId); - } catch (error) { - console.error('关注用户失败:', error); - // 失败回滚状态 - set(state => ({ - users: state.users.map(u => - u.id === userId - ? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) } - : u - ), - userCache: Object.fromEntries( - Object.entries(state.userCache).map(([id, user]) => [ - id, - id === userId - ? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) } - : user - ]) + posts: state.posts.map(p => + p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p ) })); - } - }, - - // 取消关注 - unfollowUser: async (userId: string) => { - // 先更新本地状态 - set(state => ({ - users: state.users.map(u => - u.id === userId - ? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) } - : u - ), - userCache: Object.fromEntries( - Object.entries(state.userCache).map(([id, user]) => [ - id, - id === userId - ? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) } - : user - ]) - ) - })); + + try { + const updatedPost = await postService.unfavoritePost(postId); + if (updatedPost) { + // 使用服务器返回的数据更新 + set(state => ({ + posts: state.posts.map(p => + p.id === postId ? updatedPost : p + ) + })); + } + } catch (error) { + console.error('取消收藏帖子失败:', error); + // 回滚 + set({ posts: originalPosts }); + } + }, - // 调用API - try { - await authService.unfollowUser(userId); - } catch (error) { - console.error('取消关注用户失败:', error); - // 失败回滚状态 + // 点赞评论 - 使用乐观更新工具 + likeComment: async (commentId: string) => { + await optimisticUpdater({ + key: 'posts', + optimisticUpdate: (posts) => + posts.map(p => ({ + ...p, + top_comment: p.top_comment && p.top_comment.id === commentId + ? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 } + : p.top_comment + })), + rollbackState: (original) => original, + apiCall: () => commentService.likeComment(commentId), + errorMessage: '点赞评论失败', + }); + }, + + // 取消点赞评论 - 使用乐观更新工具 + unlikeComment: async (commentId: string) => { + await optimisticUpdater({ + key: 'posts', + optimisticUpdate: (posts) => + posts.map(p => ({ + ...p, + top_comment: p.top_comment && p.top_comment.id === commentId + ? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) } + : p.top_comment + })), + rollbackState: (original) => original, + apiCall: () => commentService.unlikeComment(commentId), + errorMessage: '取消点赞评论失败', + }); + }, + + // 关注用户 - 使用乐观更新工具 + followUser: async (userId: string) => { + const originalUsers = get().users; + const originalUserCache = get().userCache; + + // 乐观更新 users set(state => ({ users: state.users.map(u => u.id === userId ? { ...u, isFollowing: true, followersCount: u.followers_count + 1 } : u ), + })); + + // 乐观更新 userCache + set(state => ({ userCache: Object.fromEntries( Object.entries(state.userCache).map(([id, user]) => [ id, @@ -566,17 +449,66 @@ export const useUserStore = create((set, get) => ({ ]) ) })); - } - }, - - // 刷新所有数据 - refreshAll: async () => { - await Promise.all([ - get().fetchPosts('recommend', 1), - get().fetchNotificationBadge(), - ]); - }, -})); + + try { + await authService.followUser(userId); + } catch (error) { + console.error('关注用户失败:', error); + // 回滚 + set({ + users: originalUsers, + userCache: originalUserCache + }); + } + }, + + // 取消关注 - 使用乐观更新工具 + unfollowUser: async (userId: string) => { + const originalUsers = get().users; + const originalUserCache = get().userCache; + + // 乐观更新 users + set(state => ({ + users: state.users.map(u => + u.id === userId + ? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) } + : u + ), + })); + + // 乐观更新 userCache + set(state => ({ + userCache: Object.fromEntries( + Object.entries(state.userCache).map(([id, user]) => [ + id, + id === userId + ? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) } + : user + ]) + ) + })); + + try { + await authService.unfollowUser(userId); + } catch (error) { + console.error('取消关注用户失败:', error); + // 回滚 + set({ + users: originalUsers, + userCache: originalUserCache + }); + } + }, + + // 刷新所有数据 + refreshAll: async () => { + await Promise.all([ + get().fetchPosts('recommend', 1), + get().fetchNotificationBadge(), + ]); + }, + }; +}); // 导出selector hooks以优化性能 export const usePosts = () => useUserStore((state) => state.posts); diff --git a/src/utils/__tests__/optimisticUpdate.test.ts b/src/utils/__tests__/optimisticUpdate.test.ts new file mode 100644 index 0000000..39081d7 --- /dev/null +++ b/src/utils/__tests__/optimisticUpdate.test.ts @@ -0,0 +1,287 @@ +/** + * 乐观更新工具函数测试 + */ + +import { optimisticUpdate, simpleOptimisticUpdate, createOptimisticUpdaterFactory } from '../optimisticUpdate'; + +// 模拟数据 +interface TestState { + posts: Array<{ id: string; likes: number; isLiked: boolean }>; + users: Array<{ id: string; isFollowing: boolean }>; +} + +const initialState: TestState = { + posts: [ + { id: '1', likes: 10, isLiked: false }, + { id: '2', likes: 5, isLiked: true }, + ], + users: [ + { id: 'user1', isFollowing: false }, + { id: 'user2', isFollowing: true }, + ], +}; + +describe('optimisticUpdate', () => { + let currentState: TestState; + + beforeEach(() => { + currentState = JSON.parse(JSON.stringify(initialState)); + }); + + describe('成功场景', () => { + it('应该执行乐观更新并在API成功后保持更新', async () => { + const mockApiCall = jest.fn().mockResolvedValue({ success: true, likes: 11 }); + + const result = await optimisticUpdate({ + getState: () => currentState, + optimisticUpdate: (state) => ({ + ...state, + posts: state.posts.map((p) => + p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p + ), + }), + apiCall: mockApiCall, + onSuccess: (result, current) => ({ + ...current, + posts: current.posts.map((p) => + p.id === '1' ? { ...p, likes: result.likes } : p + ), + }), + }); + + // 验证API被调用 + expect(mockApiCall).toHaveBeenCalledTimes(1); + // 验证返回结果 + expect(result).toEqual({ success: true, likes: 11 }); + }); + + it('应该在乐观更新后立即改变状态', async () => { + const mockApiCall = jest.fn().mockImplementation(() => { + // 在API调用期间验证状态已被乐观更新 + expect(currentState.posts[0].isLiked).toBe(true); + expect(currentState.posts[0].likes).toBe(11); + return Promise.resolve({ success: true }); + }); + + await optimisticUpdate({ + getState: () => currentState, + optimisticUpdate: (state) => { + const newState = { + ...state, + posts: state.posts.map((p) => + p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p + ), + }; + currentState = newState; // 模拟状态更新 + return newState; + }, + apiCall: mockApiCall, + }); + }); + }); + + describe('失败回滚场景', () => { + it('应该在API失败时回滚到原始状态', async () => { + const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error')); + const onError = jest.fn((error, original) => original); + + const result = await optimisticUpdate({ + getState: () => currentState, + optimisticUpdate: (state) => ({ + ...state, + posts: state.posts.map((p) => + p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p + ), + }), + apiCall: mockApiCall, + onError, + errorMessage: '点赞失败', + }); + + // 验证API被调用 + expect(mockApiCall).toHaveBeenCalledTimes(1); + // 验证onError被调用 + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + initialState + ); + // 验证返回undefined(因为失败了) + expect(result).toBeUndefined(); + }); + + it('应该在静默模式下不抛出错误', async () => { + const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error')); + + // 不应该抛出错误 + await expect( + optimisticUpdate({ + getState: () => currentState, + optimisticUpdate: (state) => state, + apiCall: mockApiCall, + silent: true, + }) + ).resolves.toBeUndefined(); + }); + + it('应该在非静默模式下抛出错误', async () => { + const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error')); + + // 应该抛出错误 + await expect( + optimisticUpdate({ + getState: () => currentState, + optimisticUpdate: (state) => state, + apiCall: mockApiCall, + silent: false, + }) + ).rejects.toThrow('Network error'); + }); + }); + + describe('错误处理', () => { + it('应该记录错误日志', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const mockApiCall = jest.fn().mockRejectedValue(new Error('API Error')); + + await optimisticUpdate({ + getState: () => currentState, + optimisticUpdate: (state) => state, + apiCall: mockApiCall, + errorMessage: '自定义错误消息', + silent: true, + }); + + expect(consoleSpy).toHaveBeenCalledWith('自定义错误消息:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + }); +}); + +describe('simpleOptimisticUpdate', () => { + let currentState: TestState; + let setStateMock: jest.Mock; + + beforeEach(() => { + currentState = JSON.parse(JSON.stringify(initialState)); + setStateMock = jest.fn((newState) => { + currentState = newState; + }); + }); + + it('应该成功执行乐观更新', async () => { + const mockApiCall = jest.fn().mockResolvedValue(undefined); + + await simpleOptimisticUpdate({ + getState: () => currentState, + setState: setStateMock, + optimisticUpdate: (state) => ({ + ...state, + posts: state.posts.map((p) => + p.id === '1' ? { ...p, likes: p.likes + 1 } : p + ), + }), + rollbackState: (original) => original, + apiCall: mockApiCall, + }); + + expect(mockApiCall).toHaveBeenCalledTimes(1); + expect(setStateMock).toHaveBeenCalledTimes(1); + }); + + it('应该在失败时回滚状态', async () => { + const mockApiCall = jest.fn().mockRejectedValue(new Error('Error')); + const rollbackMock = jest.fn((original) => original); + + await simpleOptimisticUpdate({ + getState: () => currentState, + setState: setStateMock, + optimisticUpdate: (state) => ({ + ...state, + posts: state.posts.map((p) => + p.id === '1' ? { ...p, likes: p.likes + 1 } : p + ), + }), + rollbackState: rollbackMock, + apiCall: mockApiCall, + silent: true, + }); + + // 验证乐观更新和回滚都被调用 + expect(setStateMock).toHaveBeenCalledTimes(2); + expect(rollbackMock).toHaveBeenCalledWith(initialState); + }); +}); + +describe('createOptimisticUpdaterFactory', () => { + interface StoreState { + posts: Array<{ id: string; likes: number }>; + count: number; + } + + let storeState: StoreState; + let setMock: jest.Mock; + let getMock: jest.Mock; + + beforeEach(() => { + storeState = { + posts: [{ id: '1', likes: 10 }], + count: 0, + }; + setMock = jest.fn((fn) => { + storeState = fn(storeState); + }); + getMock = jest.fn(() => storeState); + }); + + it('应该创建乐观更新器并正常工作', async () => { + const optimisticUpdater = createOptimisticUpdaterFactory(setMock, getMock); + const mockApiCall = jest.fn().mockResolvedValue(undefined); + + await optimisticUpdater({ + key: 'posts', + optimisticUpdate: (posts) => + posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)), + rollbackState: (original) => original, + apiCall: mockApiCall, + }); + + expect(mockApiCall).toHaveBeenCalledTimes(1); + expect(setMock).toHaveBeenCalledTimes(1); + expect(storeState.posts[0].likes).toBe(11); + }); + + it('应该在API失败时回滚状态', async () => { + const optimisticUpdater = createOptimisticUpdaterFactory(setMock, getMock); + const mockApiCall = jest.fn().mockRejectedValue(new Error('Error')); + + await optimisticUpdater({ + key: 'posts', + optimisticUpdate: (posts) => + posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)), + rollbackState: (original) => original, + apiCall: mockApiCall, + silent: true, + }); + + // 乐观更新 + 回滚 = 2次set调用 + expect(setMock).toHaveBeenCalledTimes(2); + // 最终状态应该和初始状态一样 + expect(storeState.posts[0].likes).toBe(10); + }); + + it('应该处理不同的state key', async () => { + const optimisticUpdater = createOptimisticUpdaterFactory(setMock, getMock); + const mockApiCall = jest.fn().mockResolvedValue(undefined); + + await optimisticUpdater({ + key: 'count', + optimisticUpdate: (count) => count + 1, + rollbackState: (original) => original, + apiCall: mockApiCall, + }); + + expect(storeState.count).toBe(1); + }); +}); diff --git a/src/utils/optimisticUpdate.ts b/src/utils/optimisticUpdate.ts new file mode 100644 index 0000000..45d4ce1 --- /dev/null +++ b/src/utils/optimisticUpdate.ts @@ -0,0 +1,201 @@ +/** + * 乐观更新工具函数 + * 用于统一管理乐观更新逻辑,减少重复代码 + */ + +export interface OptimisticUpdateOptions { + /** 获取当前状态 */ + getState: () => T; + /** 执行乐观更新 */ + optimisticUpdate: (current: T) => T; + /** API调用函数 */ + apiCall: () => Promise; + /** API成功时的回调,返回新的状态 */ + onSuccess?: (result: R, current: T) => T; + /** API失败时的回滚回调 */ + onError?: (error: any, original: T) => T; + /** 错误消息 */ + errorMessage?: string; + /** 是否静默处理错误(不抛出) */ + silent?: boolean; +} + +/** + * 执行乐观更新 + * + * 流程: + * 1. 保存原始状态 + * 2. 执行乐观更新 + * 3. 调用API + * 4. 成功时使用服务器数据更新状态 + * 5. 失败时回滚到原始状态 + * + * @template T 状态类型 + * @template R API返回类型 + * @param options 配置选项 + * @returns Promise API返回结果 + */ +export async function optimisticUpdate( + options: OptimisticUpdateOptions +): Promise { + const { + getState, + optimisticUpdate, + apiCall, + onSuccess, + onError, + errorMessage = '操作失败', + silent = true, + } = options; + + // 保存原始状态 + const originalState = getState(); + + // 执行乐观更新 + const optimisticState = optimisticUpdate(originalState); + + try { + // 调用API + const result = await apiCall(); + + // API成功,使用服务器数据更新状态(如果提供了onSuccess) + if (onSuccess) { + onSuccess(result, optimisticState); + } + + return result; + } catch (error) { + console.error(`${errorMessage}:`, error); + + // API失败,回滚状态 + if (onError) { + onError(error, originalState); + } + + // 如果不是静默模式,抛出错误 + if (!silent) { + throw error; + } + + return undefined; + } +} + +/** + * 简化版的乐观更新(适用于不需要服务器返回数据的场景) + * + * @template T 状态类型 + * @param options 配置选项 + */ +export async function simpleOptimisticUpdate( + options: Omit, 'onSuccess' | 'onError'> & { + setState: (state: T) => void; + rollbackState: (original: T) => T; + } +): Promise { + const { + getState, + setState, + optimisticUpdate, + rollbackState, + apiCall, + errorMessage = '操作失败', + silent = true, + } = options; + + // 保存原始状态 + const originalState = getState(); + + // 执行乐观更新 + setState(optimisticUpdate(originalState)); + + try { + // 调用API + await apiCall(); + } catch (error) { + console.error(`${errorMessage}:`, error); + + // API失败,回滚状态 + setState(rollbackState(originalState)); + + // 如果不是静默模式,抛出错误 + if (!silent) { + throw error; + } + } +} + +/** + * 创建乐观更新器(用于Zustand store) + * + * 使用方式: + * ```typescript + * const createOptimisticUpdater = createOptimisticUpdaterFactory(set, get); + * + * const likePost = async (postId: string) => { + * await createOptimisticUpdater({ + * getCurrentState: () => get().posts, + * setState: (posts) => set({ posts }), + * optimisticUpdate: (posts) => posts.map(...), + * rollbackState: (originalPosts) => originalPosts, + * apiCall: () => postService.likePost(postId), + * errorMessage: '点赞失败', + * }); + * }; + * ``` + */ +export function createOptimisticUpdaterFactory>( + set: (fn: (state: T) => T) => void, + get: () => T +) { + return function optimisticUpdater( + options: { + key: K; + optimisticUpdate: (current: T[K]) => T[K]; + rollbackState: (original: T[K]) => T[K]; + apiCall: () => Promise; + errorMessage?: string; + silent?: boolean; + } + ): Promise { + const { + key, + optimisticUpdate, + rollbackState, + apiCall, + errorMessage = '操作失败', + silent = true, + } = options; + + // 保存原始状态 + const originalState = get()[key]; + + // 执行乐观更新 + set((state) => ({ + ...state, + [key]: optimisticUpdate(state[key]), + })); + + return new Promise((resolve, reject) => { + apiCall() + .then(() => { + resolve(); + }) + .catch((error) => { + console.error(`${errorMessage}:`, error); + + // 回滚状态 + set((state) => ({ + ...state, + [key]: rollbackState(originalState), + })); + + if (silent) { + resolve(); + } else { + reject(error); + } + }); + }); + }; +} diff --git a/src/utils/responsive.ts b/src/utils/responsive.ts index 80d9b3d..1a3f629 100644 --- a/src/utils/responsive.ts +++ b/src/utils/responsive.ts @@ -4,7 +4,7 @@ */ import { ViewStyle } from 'react-native'; -import { BREAKPOINTS } from '../hooks/useResponsive'; +import { BREAKPOINTS } from '../presentation/hooks/responsive'; /** * 断点键名