Compare commits
50 Commits
23931df1da
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2014be28c4 | |||
|
|
619f08275c | ||
|
|
f875b417c8 | ||
|
|
4ee3079b9f | ||
|
|
90d834695f | ||
|
|
dc8f9061ab | ||
|
|
583ac64dfd | ||
|
|
cedb8284ba | ||
|
|
c12b98e293 | ||
|
|
9e41871b91 | ||
|
|
126e204592 | ||
|
|
b49cc0f3bd | ||
|
|
2ddb9cadd8 | ||
|
|
b91e78c921 | ||
|
|
f9f4e73747 | ||
|
|
48339384d2 | ||
|
|
357c1d4995 | ||
|
|
82e99d24d8 | ||
|
|
5c9cb6acea | ||
|
|
aaf53d1c3b | ||
|
|
c98f1917f7 | ||
|
|
7305254e11 | ||
|
|
d97df0b2d4 | ||
|
|
26ae288470 | ||
|
|
88510aa6ae | ||
|
|
cee120a6f8 | ||
|
|
69e21471fe | ||
|
|
d273569911 | ||
|
|
25071d2303 | ||
|
|
37ddfa8ed6 | ||
|
|
a8c78f0c3f | ||
|
|
2c65330837 | ||
|
|
97c7762f2b | ||
| 001140c7e1 | |||
|
|
8a0aea1c59 | ||
|
|
a005fb0a15 | ||
|
|
59877e6ae3 | ||
|
|
7d9670e973 | ||
|
|
de94447844 | ||
| cf466a3959 | |||
|
|
a91637466c | ||
|
|
62b55aec31 | ||
|
|
a9c514f664 | ||
|
|
b11e6548d5 | ||
|
|
2321c2dc06 | ||
|
|
a8f7e907b6 | ||
|
|
937e8112f4 | ||
|
|
a6cdb97e24 | ||
|
|
1ffbb63753 | ||
|
|
e230e35ca2 |
@@ -94,16 +94,17 @@ jobs:
|
||||
test "${REMOTE}" = "${LOCAL}"
|
||||
|
||||
build-android-apk:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: android-builder
|
||||
container:
|
||||
image: reactnativecommunity/react-native-android:latest
|
||||
env:
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1 -Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m"
|
||||
_JAVA_OPTIONS: "-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m"
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
NODE_ENV: "production"
|
||||
NDK_NUM_JOBS: "1"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "1"
|
||||
NDK_NUM_JOBS: "4"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
||||
GRADLE_USER_HOME: /root/.gradle
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -122,7 +123,28 @@ jobs:
|
||||
node-version: '25.6.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.android/build-cache
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-node-modules
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Generate Android native project
|
||||
@@ -222,10 +244,10 @@ jobs:
|
||||
# Update gradle.properties
|
||||
cat > gradle.properties << 'PROPS_EOF'
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configureondemand=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
@@ -242,7 +264,7 @@ jobs:
|
||||
run: |
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -292,6 +314,8 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
|
||||
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
|
||||
|
||||
- name: Show image tags
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ dist-android-update.zip
|
||||
# Backend
|
||||
backend/data/
|
||||
backend/logs/
|
||||
|
||||
doc/
|
||||
docs/
|
||||
|
||||
231
ARCHITECTURE_REFACTOR_PLAN.md
Normal file
231
ARCHITECTURE_REFACTOR_PLAN.md
Normal file
@@ -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<T, R> {
|
||||
store: StoreApi<any>;
|
||||
getCurrentState: () => T;
|
||||
optimisticUpdate: (current: T) => T;
|
||||
apiCall: () => Promise<R>;
|
||||
onSuccess?: (result: R, current: T) => T;
|
||||
onError?: (error: any, original: T) => void;
|
||||
}
|
||||
|
||||
export async function optimisticUpdate<T, R>(
|
||||
options: OptimisticUpdateOptions<T, R>
|
||||
): Promise<R>
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
```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错误
|
||||
- [ ] 性能不劣化(启动时间、内存占用)
|
||||
136
App.tsx
136
App.tsx
@@ -1,137 +1,7 @@
|
||||
/**
|
||||
* 萝卜BBS - 主应用入口
|
||||
* 配置导航、主题、状态管理等
|
||||
* 应用入口已由 Expo Router 接管(package.json main: expo-router/entry)。
|
||||
* 全局 Provider 与根布局见 app/_layout.tsx。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Platform, StyleSheet } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
// 配置通知处理器 - 必须在应用启动时设置
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true, // 即使在后台也要显示通知
|
||||
shouldPlaySound: true, // 播放声音
|
||||
shouldSetBadge: true, // 设置角标
|
||||
shouldShowBanner: true, // 显示横幅
|
||||
shouldShowList: true, // 显示通知列表
|
||||
}),
|
||||
});
|
||||
|
||||
import MainNavigator from './src/navigation/MainNavigator';
|
||||
import { paperTheme } from './src/theme';
|
||||
import AppPromptBar from './src/components/common/AppPromptBar';
|
||||
import AppDialogHost from './src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from './src/services/alertOverride';
|
||||
// 数据库初始化移到 authStore 中,根据用户ID创建用户专属数据库
|
||||
|
||||
// 创建 QueryClient 实例
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
installAlertOverride();
|
||||
|
||||
// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline
|
||||
if (Platform.OS === 'web') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* 移除TextInput focus时的浏览器默认outline */
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none !important;
|
||||
outline-width: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 移除focus-visible时的默认outline */
|
||||
input:focus-visible, textarea:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 移除所有:focus相关的默认样式 */
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
|
||||
// 系统通知功能初始化
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { systemNotificationService } = await import('./src/services/systemNotificationService');
|
||||
await systemNotificationService.initialize();
|
||||
|
||||
// 初始化后台保活服务
|
||||
const { initBackgroundService } = await import('./src/services/backgroundService');
|
||||
await initBackgroundService();
|
||||
|
||||
// 监听 App 状态变化
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === 'active'
|
||||
) {
|
||||
systemNotificationService.clearBadge();
|
||||
}
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
// 监听通知点击响应
|
||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
const data = response.notification.request.content.data;
|
||||
void data;
|
||||
}
|
||||
);
|
||||
|
||||
// 监听通知到达(用于前台显示时额外处理)
|
||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
void notification;
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
notificationResponseListener.current?.remove();
|
||||
notificationReceivedSubscription.remove();
|
||||
};
|
||||
};
|
||||
|
||||
initNotifications();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<PaperProvider theme={paperTheme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StatusBar style="light" />
|
||||
<MainNavigator />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
</QueryClientProvider>
|
||||
</PaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
13
app.json
13
app.json
@@ -2,10 +2,11 @@
|
||||
"expo": {
|
||||
"name": "萝卜社区",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "carrotbbs",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
@@ -33,7 +34,7 @@
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "skin.carrot.bbs",
|
||||
"versionCode": 5,
|
||||
"versionCode": 6,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
@@ -68,6 +69,12 @@
|
||||
}
|
||||
],
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
|
||||
127
app/(app)/(tabs)/_layout.tsx
Normal file
127
app/(app)/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Platform, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAppColors, shadows } from '../../../src/theme';
|
||||
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
const TAB_BAR_MARGIN = 20;
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useAppColors();
|
||||
const { width } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pathname = usePathname();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||
|
||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||
|
||||
const tabBarStyle = useMemo(() => {
|
||||
if (hideTabBar) {
|
||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||
}
|
||||
const visibleStyle = {
|
||||
position: 'absolute' as const,
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginHorizontal: TAB_BAR_MARGIN,
|
||||
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background.paper,
|
||||
...shadows.lg,
|
||||
borderTopWidth: 0,
|
||||
elevation: 100,
|
||||
zIndex: 100,
|
||||
};
|
||||
if (isHomeStackRoute && scrollHideTabBar) {
|
||||
return {
|
||||
...visibleStyle,
|
||||
display: 'none' as const,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
overflow: 'hidden' as const,
|
||||
};
|
||||
}
|
||||
return visibleStyle;
|
||||
}, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar, colors]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primary.main,
|
||||
tabBarInactiveTintColor: colors.text.secondary,
|
||||
tabBarStyle,
|
||||
tabBarItemStyle: {
|
||||
borderRadius: 16,
|
||||
marginHorizontal: 2,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: '首页',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'home' : 'home-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="apps"
|
||||
options={{
|
||||
title: '应用',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'view-grid' : 'view-grid-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="messages"
|
||||
options={{
|
||||
title: '消息',
|
||||
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined,
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'message-text' : 'message-text-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: '我的',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'account' : 'account-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/_layout.tsx
Normal file
5
app/(app)/(tabs)/apps/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AppsStackLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/index.tsx
Normal file
5
app/(app)/(tabs)/apps/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AppsScreen } from '../../../../src/screens/apps';
|
||||
|
||||
export default function AppsRoute() {
|
||||
return <AppsScreen />;
|
||||
}
|
||||
17
app/(app)/(tabs)/apps/schedule/_layout.tsx
Normal file
17
app/(app)/(tabs)/apps/schedule/_layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ScheduleStackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="course"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'transparentModal',
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/schedule/course.tsx
Normal file
5
app/(app)/(tabs)/apps/schedule/course.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CourseDetailScreen } from '../../../../../src/screens/schedule';
|
||||
|
||||
export default function CourseDetailRoute() {
|
||||
return <CourseDetailScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/schedule/index.tsx
Normal file
5
app/(app)/(tabs)/apps/schedule/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ScheduleScreen } from '../../../../../src/screens/schedule';
|
||||
|
||||
export default function ScheduleRoute() {
|
||||
return <ScheduleScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/home/_layout.tsx
Normal file
5
app/(app)/(tabs)/home/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function HomeStackLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
5
app/(app)/(tabs)/home/index.tsx
Normal file
5
app/(app)/(tabs)/home/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HomeScreen } from '../../../../src/screens/home';
|
||||
|
||||
export default function HomeRoute() {
|
||||
return <HomeScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/home/search.tsx
Normal file
5
app/(app)/(tabs)/home/search.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SearchScreen } from '../../../../src/screens/home';
|
||||
|
||||
export default function HomeSearchRoute() {
|
||||
return <SearchScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/messages/_layout.tsx
Normal file
5
app/(app)/(tabs)/messages/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MessagesStackLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
5
app/(app)/(tabs)/messages/index.tsx
Normal file
5
app/(app)/(tabs)/messages/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MessageListScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function MessagesRoute() {
|
||||
return <MessageListScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/messages/notifications.tsx
Normal file
5
app/(app)/(tabs)/messages/notifications.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NotificationsScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function NotificationsRoute() {
|
||||
return <NotificationsScreen />;
|
||||
}
|
||||
41
app/(app)/(tabs)/profile/_layout.tsx
Normal file
41
app/(app)/(tabs)/profile/_layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { AppBackButton } from '../../../../src/components/common';
|
||||
import { useAppColors } from '../../../../src/theme';
|
||||
|
||||
export default function ProfileStackLayout() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
|
||||
const headerOptions = useMemo(
|
||||
() => ({
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
headerTitleStyle: { fontWeight: '600' as const },
|
||||
headerShadowVisible: false,
|
||||
headerBackTitle: '',
|
||||
}),
|
||||
[colors]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...headerOptions,
|
||||
headerBackVisible: false,
|
||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
|
||||
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
|
||||
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
|
||||
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
|
||||
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/account-security.tsx
Normal file
5
app/(app)/(tabs)/profile/account-security.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AccountSecurityScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountSecurityRoute() {
|
||||
return <AccountSecurityScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/blocked-users.tsx
Normal file
5
app/(app)/(tabs)/profile/blocked-users.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BlockedUsersScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function BlockedUsersRoute() {
|
||||
return <BlockedUsersScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/bookmarks.tsx
Normal file
5
app/(app)/(tabs)/profile/bookmarks.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function BookmarksRoute() {
|
||||
return <ProfileScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/edit-profile.tsx
Normal file
5
app/(app)/(tabs)/profile/edit-profile.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EditProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function EditProfileRoute() {
|
||||
return <EditProfileScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/index.tsx
Normal file
5
app/(app)/(tabs)/profile/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function ProfileRoute() {
|
||||
return <ProfileScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/my-posts.tsx
Normal file
5
app/(app)/(tabs)/profile/my-posts.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function MyPostsRoute() {
|
||||
return <ProfileScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/notification-settings.tsx
Normal file
5
app/(app)/(tabs)/profile/notification-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function NotificationSettingsRoute() {
|
||||
return <NotificationSettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/settings.tsx
Normal file
5
app/(app)/(tabs)/profile/settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function SettingsRoute() {
|
||||
return <SettingsScreen />;
|
||||
}
|
||||
19
app/(app)/_layout.tsx
Normal file
19
app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
|
||||
export default function AppLayout() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
28
app/(app)/_layout.web.tsx
Normal file
28
app/(app)/_layout.web.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
|
||||
export default function AppLayoutWeb() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const useDesktopShell = width >= BREAKPOINTS.desktop;
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
if (useDesktopShell) {
|
||||
return <AppDesktopShell />;
|
||||
}
|
||||
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
5
app/(app)/chat/[conversationId].tsx
Normal file
5
app/(app)/chat/[conversationId].tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function ChatRoute() {
|
||||
return <ChatScreen />;
|
||||
}
|
||||
5
app/(app)/chat/private-info.tsx
Normal file
5
app/(app)/chat/private-info.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivateChatInfoScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function PrivateChatInfoRoute() {
|
||||
return <PrivateChatInfoScreen />;
|
||||
}
|
||||
5
app/(app)/group/[groupId]/index.tsx
Normal file
5
app/(app)/group/[groupId]/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { GroupInfoScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function GroupInfoRoute() {
|
||||
return <GroupInfoScreen />;
|
||||
}
|
||||
5
app/(app)/group/[groupId]/members.tsx
Normal file
5
app/(app)/group/[groupId]/members.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { GroupMembersScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function GroupMembersRoute() {
|
||||
return <GroupMembersScreen />;
|
||||
}
|
||||
5
app/(app)/group/create.tsx
Normal file
5
app/(app)/group/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CreateGroupScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function CreateGroupRoute() {
|
||||
return <CreateGroupScreen />;
|
||||
}
|
||||
5
app/(app)/group/invite.tsx
Normal file
5
app/(app)/group/invite.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen';
|
||||
|
||||
export default function GroupInviteRoute() {
|
||||
return <GroupInviteDetailScreen />;
|
||||
}
|
||||
5
app/(app)/group/join.tsx
Normal file
5
app/(app)/group/join.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { JoinGroupScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function JoinGroupRoute() {
|
||||
return <JoinGroupScreen />;
|
||||
}
|
||||
5
app/(app)/group/request.tsx
Normal file
5
app/(app)/group/request.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
|
||||
|
||||
export default function GroupRequestRoute() {
|
||||
return <GroupRequestDetailScreen />;
|
||||
}
|
||||
5
app/(app)/posts/create.tsx
Normal file
5
app/(app)/posts/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CreatePostScreen } from '../../../src/screens/create';
|
||||
|
||||
export default function CreatePostRoute() {
|
||||
return <CreatePostScreen />;
|
||||
}
|
||||
5
app/(app)/qrcode/login/[sessionId].tsx
Normal file
5
app/(app)/qrcode/login/[sessionId].tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
|
||||
|
||||
export default function QrLoginConfirmRoute() {
|
||||
return <QRCodeConfirmScreen />;
|
||||
}
|
||||
5
app/(app)/users/[userId]/[type].tsx
Normal file
5
app/(app)/users/[userId]/[type].tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import FollowListScreen from '../../../../src/screens/profile/FollowListScreen';
|
||||
|
||||
export default function FollowListRoute() {
|
||||
return <FollowListScreen />;
|
||||
}
|
||||
5
app/(auth)/_layout.tsx
Normal file
5
app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
5
app/(auth)/forgot-password.tsx
Normal file
5
app/(auth)/forgot-password.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ForgotPasswordScreen } from '../../src/screens/auth';
|
||||
|
||||
export default function ForgotPasswordRoute() {
|
||||
return <ForgotPasswordScreen />;
|
||||
}
|
||||
5
app/(auth)/login.tsx
Normal file
5
app/(auth)/login.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LoginScreen } from '../../src/screens/auth';
|
||||
|
||||
export default function LoginRoute() {
|
||||
return <LoginScreen />;
|
||||
}
|
||||
5
app/(auth)/register.tsx
Normal file
5
app/(auth)/register.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RegisterScreen } from '../../src/screens/auth';
|
||||
|
||||
export default function RegisterRoute() {
|
||||
return <RegisterScreen />;
|
||||
}
|
||||
205
app/_layout.tsx
Normal file
205
app/_layout.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as SystemUI from 'expo-system-ui';
|
||||
|
||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||
import {
|
||||
ThemeBootstrap,
|
||||
useAppColors,
|
||||
usePaperThemeFromStore,
|
||||
useResolvedColorScheme,
|
||||
} from '../src/theme';
|
||||
import { AppBackButton } from '../src/components/common';
|
||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '../src/services/alertOverride';
|
||||
import { useAuthStore } from '../src/stores';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
installAlertOverride();
|
||||
|
||||
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none !important;
|
||||
outline-width: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
input:focus-visible, textarea:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
*:focus { outline: none !important; }
|
||||
*:focus-visible { outline: none !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function SystemChrome() {
|
||||
const colors = useAppColors();
|
||||
useEffect(() => {
|
||||
void SystemUI.setBackgroundColorAsync(colors.background.default);
|
||||
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', colors.background.default);
|
||||
}
|
||||
}
|
||||
}, [colors.background.default]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SessionGate({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const colors = useAppColors();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentUser().finally(() => setReady(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function NotificationBootstrap() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
|
||||
await loadNotificationPreferences();
|
||||
const { systemNotificationService } = await import('../src/services/systemNotificationService');
|
||||
await systemNotificationService.initialize();
|
||||
const { initBackgroundService } = await import('../src/services/backgroundService');
|
||||
await initBackgroundService();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
|
||||
systemNotificationService.clearBadge();
|
||||
}
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
void response.notification.request.content.data;
|
||||
}
|
||||
);
|
||||
|
||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
void notification;
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
notificationResponseListener.current?.remove();
|
||||
notificationReceivedSubscription.remove();
|
||||
};
|
||||
};
|
||||
|
||||
initNotifications();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
const resolved = useResolvedColorScheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<SystemChrome />
|
||||
<SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="post/[postId]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '',
|
||||
// 与 PostDetailScreen 的 SafeAreaView(background.default)同色,避免出现 header/内容之间的色差线
|
||||
headerStyle: { backgroundColor: colors.background.default },
|
||||
headerTintColor: colors.text.primary,
|
||||
headerShadowVisible: false,
|
||||
headerBackVisible: false,
|
||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="user/[userId]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '用户主页',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
headerBackVisible: false,
|
||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SessionGate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemedProviders({ children }: { children: React.ReactNode }) {
|
||||
const theme = usePaperThemeFromStore();
|
||||
return <PaperProvider theme={theme}>{children}</PaperProvider>;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemedProviders>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeBootstrap />
|
||||
<ThemedStack />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
</QueryClientProvider>
|
||||
</ThemedProviders>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
11
app/index.tsx
Normal file
11
app/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { useAuthStore } from '../src/stores';
|
||||
|
||||
export default function Index() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/home" />;
|
||||
}
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
5
app/post/[postId].tsx
Normal file
5
app/post/[postId].tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostDetailScreen } from '../../src/screens/home';
|
||||
|
||||
export default function PostDetailRoute() {
|
||||
return <PostDetailScreen />;
|
||||
}
|
||||
5
app/user/[userId].tsx
Normal file
5
app/user/[userId].tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { UserScreen } from '../../src/screens/profile';
|
||||
|
||||
export default function UserProfileRoute() {
|
||||
return <UserScreen />;
|
||||
}
|
||||
9
index.ts
9
index.ts
@@ -1,8 +1 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
import 'expo-router/entry';
|
||||
|
||||
3118
package-lock.json
generated
3118
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"version": "1.0.1",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
@@ -25,7 +25,9 @@
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-fetch": "~55.0.9",
|
||||
"expo-background-fetch": "~55.0.10",
|
||||
"expo-background-task": "~55.0.10",
|
||||
"expo-camera": "^55.0.10",
|
||||
"expo-constants": "~55.0.7",
|
||||
"expo-dev-client": "~55.0.10",
|
||||
"expo-file-system": "~55.0.10",
|
||||
@@ -43,6 +45,9 @@
|
||||
"expo-task-manager": "~55.0.9",
|
||||
"expo-updates": "^55.0.12",
|
||||
"expo-video": "^55.0.10",
|
||||
"katex": "^0.16.42",
|
||||
"markdown-it": "^14.1.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
@@ -54,6 +59,7 @@
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
|
||||
394
plans/architecture-comparison-report.md
Normal file
394
plans/architecture-comparison-report.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Messages模块与PostService/Manager架构对比分析报告
|
||||
|
||||
## 一、Messages模块架构分析
|
||||
|
||||
### 1.1 架构层次结构
|
||||
|
||||
Messages模块采用了**清晰的分层架构**,各层职责明确:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 表现层 (Screens) │
|
||||
│ ChatScreen, MessageListScreen, NotificationsScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Hooks层 │
|
||||
│ useDifferentialMessages, useChatScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 用例层 (UseCases) │
|
||||
│ ProcessMessageUseCase │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 领域层 (Entities) │
|
||||
│ Message, Conversation, GroupNotice │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 仓库层 (Repositories) │
|
||||
│ MessageRepository, IMessageRepository │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 数据源层 (DataSources) │
|
||||
│ SSEClient, LocalDataSource, ApiDataSource │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 核心组件详解
|
||||
|
||||
#### 1.2.1 领域实体层 (Core/Entities)
|
||||
- **Message.ts**: 定义消息、会话、群通知等核心领域模型
|
||||
- 包含工厂函数:`createMessage`, `createConversation`
|
||||
- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent`
|
||||
|
||||
#### 1.2.2 用例层 (Core/UseCases)
|
||||
- **ProcessMessageUseCase**: 核心业务逻辑编排器
|
||||
- 订阅SSE事件(chat, group_message, read, recall, typing, group_notice等)
|
||||
- 消息去重处理(processedMessageIds Set)
|
||||
- 已读状态保护(pendingReadMap + 版本号机制)
|
||||
- 用户信息缓存与去重请求(pendingUserRequests Map)
|
||||
- 事件发布订阅模式(subscribers Set)
|
||||
|
||||
#### 1.2.3 仓库层 (Data/Repositories)
|
||||
- **IMessageRepository**: 定义数据访问接口
|
||||
- 消息操作:getMessages, saveMessage, updateMessageStatus, markAsRead
|
||||
- 会话操作:getConversations, saveConversation, updateUnreadCount
|
||||
- 同步操作:syncMessages, getServerLastSeq
|
||||
|
||||
- **MessageRepository**: 实现接口
|
||||
- 封装SQLite数据库操作
|
||||
- 消息与CachedMessage的转换
|
||||
|
||||
#### 1.2.4 映射器层 (Data/Mappers)
|
||||
- **MessageMapper**: 数据转换
|
||||
- `fromApiResponse`: API响应 → 应用模型
|
||||
- `fromDbRecord`: 数据库记录 → 应用模型
|
||||
- `toDbRecord`: 应用模型 → 数据库记录
|
||||
- `toApiRequest`: 应用模型 → API请求
|
||||
- 创建消息片段:`createTextSegment`, `createImageSegment`
|
||||
|
||||
#### 1.2.5 差异计算基础设施 (Infrastructure/Diff)
|
||||
- **MessageDiffCalculator**: 消息列表差异计算
|
||||
- 计算added, updated, deleted, moved, unchanged
|
||||
- 变化比例检测(changeRatio > 0.5 时触发重置)
|
||||
- 增量差异计算(基于缓存)
|
||||
|
||||
- **MessageUpdateBatcher**: 批量更新处理器
|
||||
- 16ms批量间隔(约60fps)
|
||||
- 去重和合并更新
|
||||
- 100ms最大等待时间
|
||||
- 统计信息:totalBatches, totalUpdates, averageBatchSize
|
||||
|
||||
- **types.ts**: 完整的类型定义
|
||||
- MessageUpdateType枚举:ADD, UPDATE, DELETE, MOVE, BATCH_*
|
||||
- DiffResult, DiffConfig等接口
|
||||
|
||||
#### 1.2.6 Hook层
|
||||
- **useDifferentialMessages**: 差异更新Hook
|
||||
- 集成DiffCalculator和Batcher
|
||||
- 自动处理批量更新
|
||||
- 提供flush, reset, forceUpdate方法
|
||||
- 支持配置:enableDiff, enableBatching, maxMessageCount, changeRatioThreshold
|
||||
|
||||
### 1.3 数据流
|
||||
|
||||
```
|
||||
SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages
|
||||
↓ ↓
|
||||
MessageRepository 差异计算 + 批量处理
|
||||
↓ ↓
|
||||
SQLite数据库 优化后的消息列表
|
||||
```
|
||||
|
||||
### 1.4 状态管理方式
|
||||
|
||||
- **发布-订阅模式**:ProcessMessageUseCase作为事件中心
|
||||
- **本地SQLite缓存**:消息持久化存储
|
||||
- **内存状态**:通过Hook返回,组件直接消费
|
||||
- **差异更新**:通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染
|
||||
|
||||
---
|
||||
|
||||
## 二、PostService/Manager架构分析
|
||||
|
||||
### 2.1 架构层次结构
|
||||
|
||||
Post模块采用了**扁平化架构**,Service和Manager层职责混合:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 表现层 (Screens) │
|
||||
│ HomeScreen, PostDetailScreen, CreatePostScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Hooks层 │
|
||||
│ useCursorPagination (通用), usePrefetch │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Store层 (Zustand) │
|
||||
│ useUserStore (直接操作状态), postManager (缓存管理) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Service层 │
|
||||
│ postService (API调用 + 状态更新) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 数据源 │
|
||||
│ API (直接调用) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 核心组件详解
|
||||
|
||||
#### 2.2.1 Service层 (Services)
|
||||
- **postService.ts**: 帖子服务
|
||||
- CRUD操作:getPosts, getPost, createPost, updatePost, deletePost
|
||||
- 互动操作:likePost, unlikePost, favoritePost, unfavoritePost
|
||||
- 分页支持:传统分页 + 游标分页
|
||||
- **问题**:直接操作useUserStore(违反分层原则)
|
||||
|
||||
#### 2.2.2 Manager层 (Stores)
|
||||
- **postManager.ts**: 帖子缓存管理器
|
||||
- 继承CacheBus(发布订阅基类)
|
||||
- 内存缓存:listCache, detailCache
|
||||
- 请求去重:pendingRequests Map
|
||||
- TTL管理:LIST_TTL=30s, DETAIL_TTL=60s
|
||||
- 后台刷新机制
|
||||
|
||||
#### 2.2.3 Store层 (Zustand)
|
||||
- **useUserStore**: 用户状态存储
|
||||
- 直接存储posts数组
|
||||
- 帖子操作副作用直接修改状态
|
||||
- 混合了用户数据和帖子数据
|
||||
|
||||
#### 2.2.4 映射器层 (Data/Mappers)
|
||||
- **PostMapper**: 数据转换
|
||||
- `fromApiResponse`: API响应 → 应用模型
|
||||
- `fromApiResponseList`: 批量转换
|
||||
- `toApiRequest`: 应用模型 → API请求
|
||||
- 相比MessageMapper缺少数据库记录转换方法
|
||||
|
||||
### 2.3 数据流
|
||||
|
||||
```
|
||||
HomeScreen → useCursorPagination → postService.getPostsCursor
|
||||
↓
|
||||
useUserStore.setState(posts)
|
||||
↓
|
||||
PostCard组件渲染
|
||||
```
|
||||
|
||||
### 2.4 状态管理方式
|
||||
|
||||
- **Zustand Store**:集中式状态管理
|
||||
- **内存缓存**:postManager提供应用级缓存
|
||||
- **请求去重**:dedupe方法防止重复请求
|
||||
- **直接修改**:postService直接调用useUserStore.setState
|
||||
|
||||
---
|
||||
|
||||
## 三、主要架构差异
|
||||
|
||||
### 3.1 分层复杂度
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 层次深度 | 6层(Entity→UseCase→Repository→Mapper→Hook→Screen) | 4层(Service→Store→Hook→Screen) |
|
||||
| 用例层 | 独立ProcessMessageUseCase | 无 |
|
||||
| 仓库接口 | IMessageRepository抽象接口 | 无 |
|
||||
| 基础设施 | Diff模块(Calculator + Batcher) | 无 |
|
||||
|
||||
### 3.2 数据持久化
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 本地存储 | SQLite数据库 | 无 |
|
||||
| 缓存抽象 | MessageRepository封装 | postManager内存缓存 |
|
||||
| 离线支持 | 完整 | 不支持 |
|
||||
|
||||
### 3.3 实时更新
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 推送机制 | SSE实时推送 | 轮询/下拉刷新 |
|
||||
| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 |
|
||||
| 增量更新 | MessageDiffCalculator | 无 |
|
||||
|
||||
### 3.4 状态管理
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store |
|
||||
| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore |
|
||||
| 批量更新 | MessageUpdateBatcher | 无 |
|
||||
| 差异检测 | MessageDiffCalculator | 无 |
|
||||
|
||||
### 3.5 依赖方向
|
||||
|
||||
**Messages模块(正确)**:
|
||||
```
|
||||
Hook → UseCase → Repository → DataSource
|
||||
↓
|
||||
Entity(不依赖外部)
|
||||
```
|
||||
|
||||
**Post模块(问题)**:
|
||||
```
|
||||
Service → Store(循环依赖风险)
|
||||
↓
|
||||
API直接调用
|
||||
```
|
||||
|
||||
### 3.6 核心问题总结
|
||||
|
||||
| # | 问题 | Messages | Post |
|
||||
|---|------|---------|------|
|
||||
| 1 | 违反分层原则 | 无 | postService直接操作useUserStore |
|
||||
| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 |
|
||||
| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 |
|
||||
| 4 | 无差异更新 | useDifferentialMessages | 无 |
|
||||
| 5 | 无批量处理 | MessageUpdateBatcher | 无 |
|
||||
| 6 | 无本地持久化 | SQLite | 无 |
|
||||
| 7 | 无实时推送 | SSE | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 四、对齐建议
|
||||
|
||||
### 4.1 架构重构目标
|
||||
|
||||
将Post模块对齐到Messages模块的架构模式:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 表现层 (Screens) │
|
||||
│ HomeScreen, PostDetailScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Hooks层 │
|
||||
│ useDifferentialPosts (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 用例层 (UseCases) │
|
||||
│ ProcessPostUseCase (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 领域层 (Entities) │
|
||||
│ Post, PostComment (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 仓库层 (Repositories) │
|
||||
│ PostRepository, IPostRepository (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Service层 │
|
||||
│ postService (仅保留API调用,移除状态操作) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 具体改造项
|
||||
|
||||
#### 4.2.1 创建Post领域实体
|
||||
```typescript
|
||||
// src/core/entities/Post.ts
|
||||
export interface Post {
|
||||
id: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
images: string[];
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.2 创建ProcessPostUseCase
|
||||
```typescript
|
||||
// src/core/usecases/ProcessPostUseCase.ts
|
||||
class ProcessPostUseCase {
|
||||
// 帖子增删改查
|
||||
// 点赞/收藏逻辑
|
||||
// 事件发布订阅
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.3 创建IPostRepository接口
|
||||
```typescript
|
||||
// src/data/repositories/interfaces/IPostRepository.ts
|
||||
interface IPostRepository {
|
||||
getPosts(type: string, page: number, pageSize: number): Promise<Post[]>;
|
||||
savePost(post: Post): Promise<void>;
|
||||
updatePost(postId: string, updates: Partial<Post>): Promise<void>;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.4 创建PostRepository实现
|
||||
```typescript
|
||||
// src/data/repositories/PostRepository.ts
|
||||
// 封装SQLite操作,实现IPostRepository接口
|
||||
```
|
||||
|
||||
#### 4.2.5 创建useDifferentialPosts Hook
|
||||
```typescript
|
||||
// src/hooks/useDifferentialPosts.ts
|
||||
// 类似useDifferentialMessages,处理帖子列表差异更新
|
||||
```
|
||||
|
||||
#### 4.2.6 改造postService
|
||||
- 移除对useUserStore的直接依赖
|
||||
- 仅保留API调用职责
|
||||
|
||||
---
|
||||
|
||||
## 五、架构对比图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Messages模块 [理想架构]"
|
||||
E1[Entity<br/>Message.ts]
|
||||
U1[UseCase<br/>ProcessMessageUseCase]
|
||||
R1[Repository<br/>MessageRepository]
|
||||
M1[Mapper<br/>MessageMapper]
|
||||
H1[Hook<br/>useDifferentialMessages]
|
||||
D1[Diff基础设施<br/>DiffCalculator + Batcher]
|
||||
S1[(SQLite)]
|
||||
|
||||
E1 --> U1
|
||||
U1 --> R1
|
||||
U1 --> D1
|
||||
R1 --> S1
|
||||
M1 --> R1
|
||||
D1 --> H1
|
||||
H1 --> S1
|
||||
end
|
||||
|
||||
subgraph "Post模块 [当前架构]"
|
||||
E2[PostMapper]
|
||||
SV2[postService<br/>直接操作Store]
|
||||
ST2[useUserStore<br/>Zustand]
|
||||
H2[Hook<br/>useCursorPagination]
|
||||
PM2[postManager<br/>内存缓存]
|
||||
|
||||
E2 --> SV2
|
||||
SV2 --> ST2
|
||||
H2 --> SV2
|
||||
PM2 --> ST2
|
||||
end
|
||||
|
||||
style E1 fill:#90EE90
|
||||
style U1 fill:#90EE90
|
||||
style R1 fill:#90EE90
|
||||
style D1 fill:#90EE90
|
||||
style H1 fill:#90EE90
|
||||
style S1 fill:#90EE90
|
||||
|
||||
style SV2 fill:#FFB6C1
|
||||
style ST2 fill:#FFB6C1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、结论
|
||||
|
||||
Messages模块是一个**架构完善**的模块,具备:
|
||||
1. 清晰的分层架构
|
||||
2. 独立的业务逻辑编排层(UseCase)
|
||||
3. 完整的数据持久化(SQLite)
|
||||
4. 高效的差异更新机制
|
||||
5. 实时事件推送能力
|
||||
|
||||
Post模块当前存在以下问题:
|
||||
1. **违反分层原则**:Service直接操作Store
|
||||
2. **缺少UseCase层**:业务逻辑分散
|
||||
3. **无数据抽象**:缺少Repository接口
|
||||
4. **无差异更新**:每次全量更新导致性能问题
|
||||
5. **无本地持久化**:无法离线使用
|
||||
|
||||
建议按照对齐建议逐步重构Post模块,使其架构与Messages模块对齐。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
243
src/app-navigation/AppDesktopShell.tsx
Normal file
243
src/app-navigation/AppDesktopShell.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { usePathname, useRouter } from 'expo-router';
|
||||
|
||||
import { useAppColors, shadows, type AppColors } from '../theme';
|
||||
import { useTotalUnreadCount } from '../stores';
|
||||
import { AppRouteStack } from './AppRouteStack';
|
||||
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'AppsTab' | 'ProfileTab';
|
||||
|
||||
const SIDEBAR_WIDTH_DESKTOP = 240;
|
||||
const SIDEBAR_WIDTH_TABLET = 200;
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 72;
|
||||
|
||||
const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [
|
||||
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' },
|
||||
{ name: 'AppsTab', label: '应用', href: '/apps', icon: 'view-grid', iconOutline: 'view-grid-outline' },
|
||||
{ name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' },
|
||||
{ name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' },
|
||||
];
|
||||
|
||||
function pathToTab(pathname: string): TabName {
|
||||
if (pathname.startsWith('/messages')) return 'MessageTab';
|
||||
if (pathname.startsWith('/apps')) return 'AppsTab';
|
||||
if (pathname.startsWith('/profile')) return 'ProfileTab';
|
||||
if (pathname.startsWith('/home')) return 'HomeTab';
|
||||
return 'HomeTab';
|
||||
}
|
||||
|
||||
function createDesktopShellStyles(colors: AppColors) {
|
||||
return 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function AppDesktopShell() {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createDesktopShellStyles(colors), [colors]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
const check = () => {
|
||||
const w = window.innerWidth || document.documentElement.clientWidth;
|
||||
setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP);
|
||||
};
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
const sidebarWidth = isCollapsed
|
||||
? SIDEBAR_COLLAPSED_WIDTH
|
||||
: isDesktop
|
||||
? SIDEBAR_WIDTH_DESKTOP
|
||||
: SIDEBAR_WIDTH_TABLET;
|
||||
|
||||
const currentTab = pathToTab(pathname || '/home');
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(href: string) => {
|
||||
router.replace(href);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView
|
||||
style={[
|
||||
styles.sidebar,
|
||||
{ width: sidebarWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sidebarHeader}>
|
||||
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
||||
{!isCollapsed && <Text style={styles.logoText}>胡萝卜BBS</Text>}
|
||||
</View>
|
||||
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = currentTab === item.name;
|
||||
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.name}
|
||||
style={[
|
||||
styles.sidebarItem,
|
||||
isActive && styles.sidebarItemActive,
|
||||
isCollapsed && styles.sidebarItemCollapsed,
|
||||
]}
|
||||
onPress={() => handleTabChange(item.href)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.sidebarIconContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name={(isActive ? item.icon : item.iconOutline) as any}
|
||||
size={24}
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
{showBadge ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{!isCollapsed ? (
|
||||
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>{item.label}</Text>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
style={[styles.collapseButton, isCollapsed && styles.collapseButtonCollapsed]}
|
||||
onPress={() => setIsCollapsed((c) => !c)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isCollapsed ? 'chevron-right' : 'chevron-left'}
|
||||
size={24}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
<View style={styles.mainContent}>
|
||||
<AppRouteStack />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
8
src/app-navigation/AppRouteStack.tsx
Normal file
8
src/app-navigation/AppRouteStack.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
/**
|
||||
* 已登录区内栈:由 app/(app) 下文件系统自动注册子路由
|
||||
*/
|
||||
export function AppRouteStack() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
/**
|
||||
* CommentItem 评论项组件 - QQ频道风格
|
||||
* CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表
|
||||
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
@@ -33,6 +31,174 @@ interface CommentItemProps {
|
||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||
}
|
||||
|
||||
function createCommentItemStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
metaDot: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
smallBadge: {
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
floorPlain: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 22,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 4,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
marginLeft: 0,
|
||||
paddingLeft: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.divider,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
subReplyItemLast: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
subReplyBody: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
marginTop: 2,
|
||||
},
|
||||
subReplyContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
subReplyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
subReplyAuthor: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
moreRepliesButton: {
|
||||
marginTop: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
subActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
subActionText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const CommentItem: React.FC<CommentItemProps> = ({
|
||||
comment,
|
||||
onUserPress,
|
||||
@@ -49,6 +215,8 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
@@ -181,11 +349,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.floorTag}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
|
||||
{getFloorText(floorNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
|
||||
{getFloorText(floorNumber)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -276,8 +442,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
return (
|
||||
<View style={styles.subRepliesContainer}>
|
||||
{comment.replies.map((reply) => {
|
||||
{comment.replies.map((reply, replyIndex) => {
|
||||
const replyAuthorId = reply.author?.id || '';
|
||||
const isLastReply = replyIndex === comment.replies!.length - 1;
|
||||
// 根据 target_id 获取被回复的用户昵称
|
||||
const targetId = reply.target_id;
|
||||
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
|
||||
@@ -287,7 +454,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={reply.id}
|
||||
style={styles.subReplyItem}
|
||||
style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -320,7 +487,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</View>
|
||||
{/* 显示回复内容(如果有文字) */}
|
||||
{reply.content ? (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
||||
{reply.content}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -329,18 +496,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{/* 子评论操作按钮 */}
|
||||
<View style={styles.subReplyActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
style={styles.subActionButton}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
>
|
||||
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
回复
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* 删除按钮 - 子评论作者可见 */}
|
||||
{isSubReplyAuthor && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
style={styles.subActionButton}
|
||||
onPress={() => handleSubReplyDelete(reply)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
@@ -349,7 +516,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
size={12}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
{isDeleting ? '删除中' : '删除'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -375,11 +542,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 用户头像 */}
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
{/* 用户头像 - 略大便于点击,与正文左对齐 */}
|
||||
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
||||
<Avatar
|
||||
source={comment.author?.avatar}
|
||||
size={36}
|
||||
size={40}
|
||||
name={comment.author?.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
@@ -389,12 +556,13 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{/* 用户信息行 - QQ频道风格 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.userInfo}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Text variant="body" style={styles.username}>
|
||||
{comment.author?.nickname}
|
||||
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
||||
<Text variant="body" style={styles.username} numberOfLines={1}>
|
||||
{comment.author?.nickname || '用户'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{renderBadges()}
|
||||
<Text style={styles.metaDot}>·</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(comment.created_at || '')}
|
||||
</Text>
|
||||
@@ -473,140 +641,4 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
smallBadge: {
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
floorTag: {
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
floorText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
subReplyContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
subReplyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
subReplyAuthor: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
moreRepliesButton: {
|
||||
marginTop: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default CommentItem;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* NotificationItem 通知项组件
|
||||
* 根据通知类型显示不同图标和内容
|
||||
*
|
||||
* @deprecated 请使用 SystemMessageItem 组件代替
|
||||
* 该组件使用旧的 Notification 类型,新功能请使用 SystemMessageItem
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
import { Notification, NotificationType } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// 通知类型到图标和颜色的映射
|
||||
const getNotificationIcon = (type: NotificationType): { icon: string; color: string } => {
|
||||
switch (type) {
|
||||
case 'like_post':
|
||||
return { icon: 'heart', color: colors.error.main };
|
||||
case 'like_comment':
|
||||
return { icon: 'heart', color: colors.error.main };
|
||||
case 'comment':
|
||||
return { icon: 'comment', color: colors.info.main };
|
||||
case 'reply':
|
||||
return { icon: 'reply', color: colors.info.main };
|
||||
case 'follow':
|
||||
return { icon: 'account-plus', color: colors.primary.main };
|
||||
case 'mention':
|
||||
return { icon: 'at', color: colors.warning.main };
|
||||
case 'system':
|
||||
return { icon: 'information', color: colors.text.secondary };
|
||||
default:
|
||||
return { icon: 'bell', color: colors.text.secondary };
|
||||
}
|
||||
};
|
||||
|
||||
const NotificationItem: React.FC<NotificationItemProps> = ({
|
||||
notification,
|
||||
onPress,
|
||||
}) => {
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color } = getNotificationIcon(notification.type);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, !notification.isRead && styles.unread]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* 通知图标/头像 */}
|
||||
<View style={styles.iconContainer}>
|
||||
{notification.type === 'follow' && notification.data.userId ? (
|
||||
<Avatar
|
||||
source={null}
|
||||
size={40}
|
||||
name={notification.title}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.iconWrapper, { backgroundColor: color + '20' }]}>
|
||||
<MaterialCommunityIcons name={icon as any} size={20} color={color} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 通知内容 */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.textContainer}>
|
||||
<Text
|
||||
variant="body"
|
||||
numberOfLines={2}
|
||||
style={!notification.isRead ? styles.unreadText : undefined}
|
||||
>
|
||||
{notification.content}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{formatTime(notification.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 未读标记 */}
|
||||
{!notification.isRead && <View style={styles.unreadDot} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
unread: {
|
||||
backgroundColor: colors.primary.light + '10',
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
textContainer: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
unreadText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary.main,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationItem;
|
||||
@@ -1,929 +0,0 @@
|
||||
/**
|
||||
* PostCard 帖子卡片组件 - QQ频道风格(响应式版本)
|
||||
* 简洁的帖子展示,无卡片边框
|
||||
* 支持响应式布局,宽屏下优化显示
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { ImageGrid, ImageGridItem, SmartImage } from '../common';
|
||||
import VotePreview from './VotePreview';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
||||
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
onPress: () => void;
|
||||
onUserPress: () => void;
|
||||
onLike: () => void;
|
||||
onComment: () => void;
|
||||
onBookmark: () => void;
|
||||
onShare: () => void;
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
||||
onDelete?: () => void;
|
||||
compact?: boolean;
|
||||
index?: number;
|
||||
isAuthor?: boolean;
|
||||
isPostAuthor?: boolean; // 当前用户是否为帖子作者
|
||||
variant?: 'default' | 'grid';
|
||||
}
|
||||
|
||||
const PostCard: React.FC<PostCardProps> = ({
|
||||
post,
|
||||
onPress,
|
||||
onUserPress,
|
||||
onLike,
|
||||
onComment,
|
||||
onBookmark,
|
||||
onShare,
|
||||
onImagePress,
|
||||
onDelete,
|
||||
compact = false,
|
||||
index,
|
||||
isAuthor = false,
|
||||
isPostAuthor = false,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 使用响应式 hook
|
||||
const {
|
||||
width,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWideScreen,
|
||||
isLandscape,
|
||||
orientation
|
||||
} = useResponsive();
|
||||
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
|
||||
// 响应式字体大小
|
||||
const responsiveFontSize = useResponsiveValue({
|
||||
xs: fontSizes.md,
|
||||
sm: fontSizes.md,
|
||||
md: fontSizes.lg,
|
||||
lg: isLandscape ? fontSizes.lg : fontSizes.xl,
|
||||
xl: fontSizes.xl,
|
||||
'2xl': fontSizes.xl,
|
||||
'3xl': fontSizes['2xl'],
|
||||
'4xl': fontSizes['2xl'],
|
||||
});
|
||||
|
||||
// 响应式标题字体大小
|
||||
const responsiveTitleFontSize = useResponsiveValue({
|
||||
xs: fontSizes.lg,
|
||||
sm: fontSizes.lg,
|
||||
md: fontSizes.xl,
|
||||
lg: isLandscape ? fontSizes.xl : fontSizes['2xl'],
|
||||
xl: fontSizes['2xl'],
|
||||
'2xl': fontSizes['2xl'],
|
||||
'3xl': fontSizes['3xl'],
|
||||
'4xl': fontSizes['3xl'],
|
||||
});
|
||||
|
||||
// 响应式头像大小
|
||||
const avatarSize = useResponsiveValue({
|
||||
xs: 36,
|
||||
sm: 38,
|
||||
md: 40,
|
||||
lg: 44,
|
||||
xl: 48,
|
||||
'2xl': 48,
|
||||
'3xl': 52,
|
||||
'4xl': 56,
|
||||
});
|
||||
|
||||
// 响应式内边距 - 宽屏下增加更多内边距
|
||||
const responsivePadding = useResponsiveValue({
|
||||
xs: spacing.md,
|
||||
sm: spacing.md,
|
||||
md: spacing.lg,
|
||||
lg: spacing.xl,
|
||||
xl: spacing.xl * 1.2,
|
||||
'2xl': spacing.xl * 1.5,
|
||||
'3xl': spacing.xl * 2,
|
||||
'4xl': spacing.xl * 2.5,
|
||||
});
|
||||
|
||||
// 宽屏下额外的卡片内边距
|
||||
const cardPadding = useMemo(() => {
|
||||
if (isWideScreen) return spacing.xl;
|
||||
if (isDesktop) return spacing.lg;
|
||||
if (isTablet) return spacing.md;
|
||||
return 0; // 移动端无额外内边距
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
|
||||
const formatDateTime = (dateString?: string | null): string => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const pad = (num: number) => String(num).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
};
|
||||
|
||||
const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => {
|
||||
if (!createdAt || !updatedAt) return false;
|
||||
const created = new Date(createdAt).getTime();
|
||||
const updated = new Date(updatedAt).getTime();
|
||||
if (Number.isNaN(created) || Number.isNaN(updated)) return false;
|
||||
return updated - created > 1000;
|
||||
};
|
||||
|
||||
const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => {
|
||||
if (!content) return '';
|
||||
if (content.length <= maxLength || isExpanded) return content;
|
||||
return content.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
// 宽屏下显示更多内容
|
||||
const getResponsiveMaxLength = () => {
|
||||
if (isWideScreen) return 300;
|
||||
if (isDesktop) return 250;
|
||||
if (isTablet) return 200;
|
||||
return 100;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// 处理删除帖子
|
||||
const handleDelete = () => {
|
||||
if (!onDelete || isDeleting) return;
|
||||
|
||||
Alert.alert(
|
||||
'删除帖子',
|
||||
'确定要删除这篇帖子吗?删除后将无法恢复。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染图片 - 使用新的 ImageGrid 组件
|
||||
const renderImages = () => {
|
||||
if (!post.images || !Array.isArray(post.images) || post.images.length === 0) return null;
|
||||
|
||||
// 转换图片数据格式
|
||||
const gridImages: ImageGridItem[] = post.images.map(img => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}));
|
||||
|
||||
// 宽屏下显示更大的图片
|
||||
const imageGap = isDesktop ? 12 : isTablet ? 8 : 4;
|
||||
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md;
|
||||
|
||||
return (
|
||||
<View style={styles.imagesContainer}>
|
||||
<ImageGrid
|
||||
images={gridImages}
|
||||
maxDisplayCount={isWideScreen ? 12 : 9}
|
||||
mode="auto"
|
||||
gap={imageGap}
|
||||
borderRadius={imageBorderRadius}
|
||||
showMoreOverlay={true}
|
||||
onImagePress={onImagePress}
|
||||
displayMode={variant === 'grid' ? 'grid' : 'list'}
|
||||
usePreview={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBadges = () => {
|
||||
const badges = [];
|
||||
|
||||
if (isAuthor) {
|
||||
badges.push(
|
||||
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (post.author?.id === '1') {
|
||||
badges.push(
|
||||
<View key="admin" style={[styles.badge, styles.adminBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>管理员</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const renderTopComment = () => {
|
||||
if (!post.top_comment) return null;
|
||||
|
||||
const { top_comment } = post;
|
||||
|
||||
const topCommentContainerStyles = [
|
||||
styles.topCommentContainer,
|
||||
...(isDesktop ? [styles.topCommentContainerWide] : [])
|
||||
];
|
||||
|
||||
const topCommentAuthorStyles = [
|
||||
styles.topCommentAuthor,
|
||||
...(isDesktop ? [styles.topCommentAuthorWide] : [])
|
||||
];
|
||||
|
||||
const topCommentTextStyles = [
|
||||
styles.topCommentText,
|
||||
...(isDesktop ? [styles.topCommentTextWide] : [])
|
||||
];
|
||||
|
||||
const moreCommentsStyles = [
|
||||
styles.moreComments,
|
||||
...(isDesktop ? [styles.moreCommentsWide] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={topCommentContainerStyles}>
|
||||
<View style={styles.topCommentContent}>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
style={topCommentAuthorStyles}
|
||||
>
|
||||
{top_comment.author?.nickname || '匿名用户'}:
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
style={topCommentTextStyles}
|
||||
numberOfLines={isDesktop ? 2 : 1}
|
||||
>
|
||||
{top_comment.content}
|
||||
</Text>
|
||||
</View>
|
||||
{post.comments_count > 1 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.primary.main}
|
||||
style={moreCommentsStyles}
|
||||
>
|
||||
查看全部{formatNumber(post.comments_count)}条评论
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染投票预览
|
||||
const renderVotePreview = () => {
|
||||
if (!post.is_vote) return null;
|
||||
|
||||
return (
|
||||
<VotePreview
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染小红书风格的两栏卡片
|
||||
const renderGridVariant = () => {
|
||||
// 获取封面图(第一张图)
|
||||
const coverImage = post.images && Array.isArray(post.images) && post.images.length > 0 ? post.images[0] : null;
|
||||
const hasImage = !!coverImage;
|
||||
|
||||
// 防御性检查:确保 author 存在
|
||||
const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' };
|
||||
|
||||
// 根据图片实际宽高比或使用随机值创建错落感
|
||||
// 使用帖子 ID 生成一个伪随机值,确保每次渲染一致但不同帖子有差异
|
||||
const postId = post.id || '';
|
||||
const hash = postId.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
||||
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
||||
|
||||
// 获取封面图预览 URL
|
||||
const coverUrl = hasImage
|
||||
? getPreviewImageUrl(coverImage, 'grid')
|
||||
: '';
|
||||
|
||||
// 获取正文预览(无图时显示)
|
||||
const getContentPreview = (content: string | undefined | null): string => {
|
||||
if (!content) return '';
|
||||
// 移除 Markdown 标记和多余空白
|
||||
return content
|
||||
.replace(/[#*_~`\[\]()]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const contentPreview = getContentPreview(post.content);
|
||||
|
||||
// 响应式字体大小(网格模式)
|
||||
const gridTitleFontSize = isDesktop ? 16 : isTablet ? 15 : 14;
|
||||
const gridUsernameFontSize = isDesktop ? 14 : 12;
|
||||
const gridLikeFontSize = isDesktop ? 14 : 12;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.gridContainer,
|
||||
!hasImage && styles.gridContainerNoImage,
|
||||
isDesktop && styles.gridContainerDesktop
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
|
||||
{hasImage && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onImagePress?.(post.images || [], 0)}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: coverUrl }}
|
||||
style={[styles.gridCoverImage, { aspectRatio }]}
|
||||
resizeMode="cover"
|
||||
usePreview={true}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 无图时的正文预览区域 */}
|
||||
{!hasImage && contentPreview && (
|
||||
<View style={[styles.gridContentPreview, isDesktop ? styles.gridContentPreviewDesktop : null]}>
|
||||
<Text
|
||||
style={[styles.gridContentText, ...(isDesktop ? [styles.gridContentTextDesktop] : [])]}
|
||||
numberOfLines={isDesktop ? 8 : 6}
|
||||
>
|
||||
{contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 投票标识 */}
|
||||
{post.is_vote && (
|
||||
<View style={styles.gridVoteBadge}>
|
||||
<MaterialCommunityIcons name="vote" size={isDesktop ? 14 : 12} color={colors.primary.contrast} />
|
||||
<Text style={styles.gridVoteBadgeText}>投票</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 标题 - 无图时显示更多行 */}
|
||||
{post.title && (
|
||||
<Text
|
||||
style={[
|
||||
styles.gridTitle,
|
||||
{ fontSize: gridTitleFontSize },
|
||||
...(hasImage ? [] : [styles.gridTitleNoImage]),
|
||||
...(isDesktop ? [styles.gridTitleDesktop] : [])
|
||||
]}
|
||||
numberOfLines={hasImage ? 2 : 3}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 底部信息 */}
|
||||
<View style={[styles.gridFooter, isDesktop && styles.gridFooterDesktop]}>
|
||||
<TouchableOpacity style={styles.gridUserInfo} onPress={onUserPress}>
|
||||
<Avatar
|
||||
source={author.avatar}
|
||||
size={isDesktop ? 24 : 20}
|
||||
name={author.nickname}
|
||||
/>
|
||||
<Text style={[styles.gridUsername, { fontSize: gridUsernameFontSize }]} numberOfLines={1}>
|
||||
{author.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.gridLikeInfo}>
|
||||
<MaterialCommunityIcons name="heart" size={isDesktop ? 16 : 14} color="#666" />
|
||||
<Text style={[styles.gridLikeCount, { fontSize: gridLikeFontSize }]}>
|
||||
{formatNumber(post.likes_count)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据 variant 渲染不同样式
|
||||
if (variant === 'grid') {
|
||||
return renderGridVariant();
|
||||
}
|
||||
|
||||
// 防御性检查:确保 author 存在
|
||||
const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' };
|
||||
|
||||
// 计算响应式内容最大行数
|
||||
const contentNumberOfLines = useMemo(() => {
|
||||
if (isWideScreen) return 8;
|
||||
if (isDesktop) return 6;
|
||||
if (isTablet) return 5;
|
||||
return 3;
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingVertical: responsivePadding * 0.75,
|
||||
...(isDesktop || isWideScreen ? {
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: cardPadding,
|
||||
marginVertical: spacing.sm,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
} : {})
|
||||
}
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* 用户信息 */}
|
||||
<View style={styles.userSection}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Avatar
|
||||
source={author.avatar}
|
||||
size={avatarSize}
|
||||
name={author.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.userInfo}>
|
||||
<View style={styles.userNameRow}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
styles.username,
|
||||
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
|
||||
]}
|
||||
>
|
||||
{author.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{renderBadges()}
|
||||
</View>
|
||||
<View style={styles.postMeta}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
发布 {formatDateTime(post.created_at)}
|
||||
</Text>
|
||||
{isPostEdited(post.created_at, post.updated_at) && (
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{' · 修改 '}{formatDateTime(post.updated_at)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{post.is_pinned && (
|
||||
<View style={styles.pinnedTag}>
|
||||
<MaterialCommunityIcons name="pin" size={isDesktop ? 14 : 12} color={colors.warning.main} />
|
||||
<Text variant="caption" color={colors.warning.main} style={styles.pinnedText}>置顶</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 删除按钮 - 只对帖子作者显示 */}
|
||||
{isPostAuthor && onDelete && (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={isDesktop ? 20 : 18}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 标题 */}
|
||||
{post.title && (
|
||||
<Text
|
||||
variant="body"
|
||||
numberOfLines={compact ? 2 : undefined}
|
||||
style={[
|
||||
styles.title,
|
||||
{ fontSize: responsiveTitleFontSize, lineHeight: responsiveTitleFontSize * 1.4 }
|
||||
]}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
{!compact && (
|
||||
<>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
style={[
|
||||
styles.content,
|
||||
{ fontSize: responsiveFontSize, lineHeight: responsiveFontSize * 1.5 }
|
||||
]}
|
||||
>
|
||||
{getTruncatedContent(post.content, getResponsiveMaxLength())}
|
||||
</Text>
|
||||
{post.content && post.content.length > getResponsiveMaxLength() && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={styles.expandButton}
|
||||
>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
{isExpanded ? '收起' : '展开全文'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
{renderImages()}
|
||||
|
||||
{/* 投票预览 */}
|
||||
{renderVotePreview()}
|
||||
|
||||
{/* 热门评论预览 */}
|
||||
{renderTopComment()}
|
||||
|
||||
{/* 交互按钮 */}
|
||||
<View style={[styles.actionBar, isDesktop ? styles.actionBarWide : null]}>
|
||||
<View style={styles.viewCount}>
|
||||
<MaterialCommunityIcons name="eye-outline" size={isDesktop ? 18 : 16} color={colors.text.hint} />
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.hint}
|
||||
style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}
|
||||
>
|
||||
{formatNumber(post.views_count || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
/>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}
|
||||
>
|
||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onComment}>
|
||||
<MaterialCommunityIcons
|
||||
name="comment-outline"
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.secondary} style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}>
|
||||
{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onShare}>
|
||||
<MaterialCommunityIcons
|
||||
name="share-outline"
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={onBookmark}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
// 宽屏卡片样式
|
||||
wideCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
userSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
userNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
postMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
pinnedTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.warning.light + '20',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
pinnedText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
deleteButton: {
|
||||
padding: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
content: {
|
||||
marginBottom: spacing.xs,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
expandButton: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
imagesContainer: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
topCommentContainer: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.sm,
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
topCommentContainerWide: {
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
topCommentContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topCommentAuthor: {
|
||||
fontWeight: '600',
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
topCommentAuthorWide: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
topCommentText: {
|
||||
flex: 1,
|
||||
},
|
||||
topCommentTextWide: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
moreComments: {
|
||||
marginTop: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
moreCommentsWide: {
|
||||
fontSize: fontSizes.sm,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionBarWide: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
viewCount: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
actionButtonWide: {
|
||||
marginLeft: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 6,
|
||||
},
|
||||
actionTextWide: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
|
||||
// ========== 小红书风格两栏样式 ==========
|
||||
gridContainer: {
|
||||
backgroundColor: '#FFF',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
},
|
||||
gridContainerDesktop: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
gridContainerNoImage: {
|
||||
minHeight: 200,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
gridCoverImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.7,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gridCoverPlaceholder: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.7,
|
||||
backgroundColor: '#F5F5F5',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 无图时的正文预览区域
|
||||
gridContentPreview: {
|
||||
backgroundColor: '#F8F8F8',
|
||||
margin: 4,
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
minHeight: 100,
|
||||
},
|
||||
gridContentPreviewDesktop: {
|
||||
margin: 8,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
minHeight: 150,
|
||||
},
|
||||
gridContentText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
gridContentTextDesktop: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
},
|
||||
gridTitle: {
|
||||
color: '#333',
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: 4,
|
||||
paddingTop: 8,
|
||||
minHeight: 44,
|
||||
},
|
||||
gridTitleNoImage: {
|
||||
minHeight: 0,
|
||||
paddingTop: 4,
|
||||
},
|
||||
gridTitleDesktop: {
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 12,
|
||||
lineHeight: 24,
|
||||
minHeight: 56,
|
||||
},
|
||||
gridFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
gridFooterDesktop: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
gridUserInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
gridUsername: {
|
||||
color: '#666',
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
},
|
||||
gridLikeInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gridLikeCount: {
|
||||
color: '#666',
|
||||
marginLeft: 4,
|
||||
},
|
||||
gridVoteBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.main + 'CC',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.full,
|
||||
gap: 4,
|
||||
},
|
||||
gridVoteBadgeText: {
|
||||
fontSize: 10,
|
||||
color: colors.primary.contrast,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default PostCard;
|
||||
713
src/components/business/PostCard/PostCard.tsx
Normal file
713
src/components/business/PostCard/PostCard.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* PostCard 主组件
|
||||
* 帖子卡片组件(新实现)
|
||||
*
|
||||
* @example
|
||||
* // 新 API 使用方式
|
||||
* <PostCard
|
||||
* post={post}
|
||||
* onAction={(action) => handleAction(post, action)}
|
||||
* variant="list"
|
||||
* features="full"
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, memo, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PostCardProps, PostCardAction } from './types';
|
||||
import { ImageGridItem, SmartImage } from '../../common';
|
||||
import Text from '../../common/Text';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
import PostImages from './components/PostImages';
|
||||
import { useResponsive } from '../../../hooks/useResponsive';
|
||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||
|
||||
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
|
||||
const PostCardRelativeTime: React.FC<{ createdAt?: string | null; style?: TextStyle | TextStyle[] }> = ({
|
||||
createdAt,
|
||||
style: timeStyle,
|
||||
}) => {
|
||||
const [label, setLabel] = useState(() => formatPostCreatedAtString(createdAt));
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(formatPostCreatedAtString(createdAt));
|
||||
const tick = () => setLabel(formatPostCreatedAtString(createdAt));
|
||||
const id = setInterval(tick, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [createdAt]);
|
||||
|
||||
return (
|
||||
<Text style={timeStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
function imagesSignature(
|
||||
images: PostCardProps['post']['images'] | undefined
|
||||
): string {
|
||||
if (!images?.length) return '';
|
||||
return images.map((i) => i.id).join('\u001f');
|
||||
}
|
||||
|
||||
function authorSignature(author: PostCardProps['post']['author']): string {
|
||||
if (!author) return '';
|
||||
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
||||
}
|
||||
|
||||
function topCommentSignature(tc: PostCardProps['post']['top_comment']): string {
|
||||
if (!tc) return '';
|
||||
return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f');
|
||||
}
|
||||
|
||||
function channelSignature(ch: PostCardProps['post']['channel']): string {
|
||||
if (!ch) return '';
|
||||
return [ch.id, ch.name ?? ''].join('\u001f');
|
||||
}
|
||||
|
||||
function featuresComparable(f: PostCardProps['features']): string {
|
||||
if (f == null) return '';
|
||||
if (typeof f === 'string') return f;
|
||||
return JSON.stringify(f);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义比较:忽略 onAction 引用(父组件应用 postId + ref 解析最新 post,避免 Tab 切换时全列表因回调重建而重绘)。
|
||||
* index 未参与 UI,不比较以免列表重排时多余更新。
|
||||
*/
|
||||
function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolean {
|
||||
if (prev.variant !== next.variant) return false;
|
||||
if (prev.isPostAuthor !== next.isPostAuthor) return false;
|
||||
if (prev.isAuthor !== next.isAuthor) return false;
|
||||
if (prev.style !== next.style) return false;
|
||||
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
||||
|
||||
const a = prev.post;
|
||||
const b = next.post;
|
||||
if (a === b) return true;
|
||||
|
||||
if (a.id !== b.id) return false;
|
||||
if (a.title !== b.title) return false;
|
||||
if (a.content !== b.content) return false;
|
||||
if ((a.likes_count ?? 0) !== (b.likes_count ?? 0)) return false;
|
||||
if ((a.comments_count ?? 0) !== (b.comments_count ?? 0)) return false;
|
||||
if ((a.favorites_count ?? 0) !== (b.favorites_count ?? 0)) return false;
|
||||
if ((a.shares_count ?? 0) !== (b.shares_count ?? 0)) return false;
|
||||
if ((a.views_count ?? 0) !== (b.views_count ?? 0)) return false;
|
||||
if (!!a.is_pinned !== !!b.is_pinned) return false;
|
||||
if (!!a.is_locked !== !!b.is_locked) return false;
|
||||
if (!!a.is_vote !== !!b.is_vote) return false;
|
||||
if (a.created_at !== b.created_at) return false;
|
||||
if (a.updated_at !== b.updated_at) return false;
|
||||
if (a.content_edited_at !== b.content_edited_at) return false;
|
||||
if (!!a.is_liked !== !!b.is_liked) return false;
|
||||
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
||||
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
||||
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createPostCardStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
authorWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
authorMeta: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
authorName: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
timeText: {
|
||||
color: colors.text.hint,
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 2,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
channelTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
maxWidth: 140,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
gap: 4,
|
||||
},
|
||||
channelTagText: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '600',
|
||||
flexShrink: 1,
|
||||
},
|
||||
pinnedTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${colors.warning.light}22`,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
gap: 2,
|
||||
},
|
||||
pinnedText: {
|
||||
color: colors.warning.main,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButton: {
|
||||
padding: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
content: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.md,
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
expandText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
topCommentBox: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topCommentAuthor: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
},
|
||||
topCommentText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
flex: 1,
|
||||
},
|
||||
gridCover: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.78,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionsLeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
channelTagAfterViews: {
|
||||
maxWidth: 130,
|
||||
flexShrink: 1,
|
||||
},
|
||||
viewsWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
viewsText: {
|
||||
color: colors.text.hint,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
activeActionText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
activeActionTextMerged: {
|
||||
color: colors.error.main,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
gridRootCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
gridNoImagePreview: {
|
||||
backgroundColor: colors.background.default,
|
||||
margin: 6,
|
||||
borderRadius: borderRadius.md,
|
||||
minHeight: 120,
|
||||
padding: 8,
|
||||
},
|
||||
gridNoImageText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
gridVoteTag: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: `${colors.primary.main}CC`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
gridVoteTagText: {
|
||||
color: colors.primary.contrast,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
gridTitleMain: {
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 8,
|
||||
minHeight: 44,
|
||||
},
|
||||
gridChannelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
gridChannelText: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
gridFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
gridUserArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
gridUsername: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
},
|
||||
gridLikeArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gridLikeCount: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCard 主组件
|
||||
* 仅支持新 API
|
||||
*/
|
||||
const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
const {
|
||||
post,
|
||||
onAction,
|
||||
variant = 'list',
|
||||
features,
|
||||
isPostAuthor = false,
|
||||
isAuthor = false,
|
||||
index,
|
||||
style,
|
||||
} = normalizedProps;
|
||||
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createPostCardStyles(colors), [colors]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isCompact =
|
||||
features === 'compact' ||
|
||||
(typeof features === 'object' && features?.showContent === false);
|
||||
|
||||
const emit = (action: PostCardAction) => {
|
||||
onAction(action);
|
||||
};
|
||||
|
||||
const handleImagePress = (images: ImageGridItem[], imageIndex: number) => {
|
||||
emit({ type: 'imagePress', payload: { images, imageIndex } });
|
||||
};
|
||||
|
||||
const showGrid = variant === 'grid';
|
||||
const author = post.author;
|
||||
const rawContent = post.content ?? '';
|
||||
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
||||
|
||||
const handleCardPress = () => emit({ type: 'press' });
|
||||
const handleUserPress = () => emit({ type: 'userPress' });
|
||||
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
||||
const handleComment = () => emit({ type: 'comment' });
|
||||
const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' });
|
||||
const handleShare = () => emit({ type: 'share' });
|
||||
const handleDelete = () => {
|
||||
if (isDeleting) return;
|
||||
Alert.alert(
|
||||
'删除帖子',
|
||||
'确定要删除这篇帖子吗?删除后将无法恢复。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
emit({ type: 'delete' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const images: ImageGridItem[] = Array.isArray(post.images)
|
||||
? post.images.map((img) => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
|
||||
return String(num);
|
||||
};
|
||||
|
||||
const getResponsiveMaxLength = () => {
|
||||
if (isWideScreen) return 300;
|
||||
if (isDesktop) return 250;
|
||||
if (isTablet) return 200;
|
||||
return 100;
|
||||
};
|
||||
|
||||
const contentNumberOfLines = useMemo(() => {
|
||||
if (isWideScreen) return 8;
|
||||
if (isDesktop) return 6;
|
||||
if (isTablet) return 5;
|
||||
return 3;
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
|
||||
const getTruncatedContent = (value: string): string => {
|
||||
const maxLength = getResponsiveMaxLength();
|
||||
if (value.length <= maxLength || isExpanded) return value;
|
||||
return `${value.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
||||
const content = useMemo(
|
||||
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
||||
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
);
|
||||
|
||||
const renderImages = () => {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
if (showGrid) {
|
||||
const cover = images[0];
|
||||
const coverUrl = getPreviewImageUrl(cover as any, 'grid');
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
|
||||
<SmartImage
|
||||
source={{ uri: coverUrl || cover.url || '' }}
|
||||
style={styles.gridCover}
|
||||
resizeMode="cover"
|
||||
usePreview={true}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PostImages
|
||||
images={post.images || []}
|
||||
displayMode="list"
|
||||
onImagePress={handleImagePress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTopComment = () => {
|
||||
if (showGrid || !post.top_comment) return null;
|
||||
return (
|
||||
<TouchableOpacity style={styles.topCommentBox} activeOpacity={0.85} onPress={handleComment}>
|
||||
<Text style={styles.topCommentAuthor} numberOfLines={1}>
|
||||
{post.top_comment.author?.nickname || '匿名用户'}:
|
||||
</Text>
|
||||
<Text style={styles.topCommentText} numberOfLines={1}>
|
||||
{post.top_comment.content}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGridCard = () => {
|
||||
const cover = images[0];
|
||||
const hasImage = !!cover;
|
||||
const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : '';
|
||||
const contentPreview = rawContent
|
||||
.replace(/[#*_~`\[\]()]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleCardPress}
|
||||
style={[styles.gridRootCard, style]}
|
||||
>
|
||||
{hasImage ? (
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
|
||||
<SmartImage
|
||||
source={{ uri: coverUrl || cover.url || '' }}
|
||||
style={styles.gridCover}
|
||||
resizeMode="cover"
|
||||
usePreview={true}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.gridNoImagePreview}>
|
||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||
{contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{post.is_vote && (
|
||||
<View style={styles.gridVoteTag}>
|
||||
<MaterialCommunityIcons name="vote" size={11} color={colors.primary.contrast} />
|
||||
<Text style={styles.gridVoteTagText}>投票</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!post.channel?.name && (
|
||||
<View style={styles.gridChannelRow}>
|
||||
<MaterialCommunityIcons name="tag-outline" size={12} color={colors.text.hint} />
|
||||
<Text style={styles.gridChannelText} numberOfLines={1}>
|
||||
{post.channel.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.gridFooter}>
|
||||
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
|
||||
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
|
||||
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.gridLikeArea}>
|
||||
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
||||
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
showGrid ? renderGridCard() : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleCardPress}
|
||||
style={[styles.card, style]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleUserPress} activeOpacity={0.8} style={styles.authorWrap}>
|
||||
<Avatar source={author?.avatar} size={showGrid ? 22 : 36} name={author?.nickname || '匿名用户'} />
|
||||
<View style={styles.authorMeta}>
|
||||
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||
{!showGrid && (
|
||||
<View style={styles.metaRow}>
|
||||
<PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
|
||||
{post.is_pinned && (
|
||||
<View style={styles.pinnedTag}>
|
||||
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
|
||||
<Text style={styles.pinnedText}>置顶</Text>
|
||||
</View>
|
||||
)}
|
||||
{isAuthor && <Text style={styles.badgeText}>楼主</Text>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{isPostAuthor && (
|
||||
<TouchableOpacity onPress={handleDelete} hitSlop={8} style={styles.deleteButton} disabled={isDeleting}>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={isDesktop ? 20 : 18}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isCompact && !!content && (
|
||||
<>
|
||||
<Text
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderImages()}
|
||||
{renderTopComment()}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<View style={styles.actionsLeading}>
|
||||
<View style={styles.viewsWrap}>
|
||||
<MaterialCommunityIcons name="eye-outline" size={16} color={colors.text.hint} />
|
||||
<Text style={styles.viewsText}>{post.views_count || 0}</Text>
|
||||
</View>
|
||||
{!!post.channel?.name && (
|
||||
<View style={[styles.channelTag, styles.channelTagAfterViews]}>
|
||||
<MaterialCommunityIcons name="tag-outline" size={11} color={colors.primary.main} />
|
||||
<Text style={styles.channelTagText} numberOfLines={1}>
|
||||
{post.channel.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={18}
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
/>
|
||||
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleComment}>
|
||||
<MaterialCommunityIcons name="comment-outline" size={18} color={colors.text.secondary} />
|
||||
<Text style={styles.actionText}>{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
|
||||
<MaterialCommunityIcons name="share-outline" size={18} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||
size={18}
|
||||
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
PostCardInner.displayName = 'PostCard';
|
||||
|
||||
const PostCard = memo(PostCardInner, arePostCardPropsEqual);
|
||||
|
||||
export default PostCard;
|
||||
53
src/components/business/PostCard/components/PostImages.tsx
Normal file
53
src/components/business/PostCard/components/PostImages.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* PostImages 子组件
|
||||
* 显示帖子图片网格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { spacing, borderRadius } from '../../../../theme';
|
||||
import { useResponsive } from '../../../../hooks/useResponsive';
|
||||
import { ImageGrid, ImageGridItem } from '../../../common';
|
||||
import { PostImagesProps } from '../types';
|
||||
|
||||
const PostImages: React.FC<PostImagesProps> = ({ images, displayMode, onImagePress }) => {
|
||||
const { isDesktop, isWideScreen } = useResponsive();
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
// 计算图片间距和圆角
|
||||
const imageGap = isDesktop ? 4 : 2;
|
||||
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ImageGrid
|
||||
images={images.map((img) => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))}
|
||||
maxDisplayCount={isWideScreen ? 12 : 9}
|
||||
mode="auto"
|
||||
gap={imageGap}
|
||||
borderRadius={imageBorderRadius}
|
||||
showMoreOverlay={true}
|
||||
onImagePress={onImagePress}
|
||||
displayMode={displayMode}
|
||||
usePreview={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
});
|
||||
|
||||
export default PostImages;
|
||||
5
src/components/business/PostCard/components/index.ts
Normal file
5
src/components/business/PostCard/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* PostCard 子组件导出(仅导出仓库内已存在的实现)
|
||||
*/
|
||||
|
||||
export { default as PostImages } from './PostImages';
|
||||
5
src/components/business/PostCard/hooks/index.ts
Normal file
5
src/components/business/PostCard/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* PostCard Hooks(预留入口;实现文件尚未加入仓库时勿导出,避免 TS 无法解析)
|
||||
*/
|
||||
|
||||
export {};
|
||||
30
src/components/business/PostCard/index.ts
Normal file
30
src/components/business/PostCard/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* PostCard 组件模块导出
|
||||
*/
|
||||
|
||||
// 主组件(默认导出)
|
||||
export { default } from './PostCard';
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
PostCardActionType,
|
||||
PostCardActionPayload,
|
||||
PostCardAction,
|
||||
PostCardOnAction,
|
||||
PostCardFeatures,
|
||||
PostCardFeaturesPreset,
|
||||
PostCardFeaturesConfig,
|
||||
PostCardProps,
|
||||
PostHeaderProps,
|
||||
PostTitleProps,
|
||||
PostContentProps,
|
||||
PostImagesProps,
|
||||
PostActionsProps,
|
||||
PostVotePreviewProps,
|
||||
PostTopCommentProps,
|
||||
PostGridCoverProps,
|
||||
PostGridInfoProps,
|
||||
PostCardListProps,
|
||||
PostCardGridProps,
|
||||
Badge,
|
||||
} from './types';
|
||||
262
src/components/business/PostCard/types.ts
Normal file
262
src/components/business/PostCard/types.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* PostCard 组件类型定义
|
||||
* 统一的帖子卡片组件类型系统
|
||||
*/
|
||||
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
import { Post, PostImageDTO, UserDTO, CommentDTO } from '../../../types';
|
||||
import { ImageGridItem } from '../../common';
|
||||
|
||||
// ==================== Action 类型定义 ====================
|
||||
|
||||
/**
|
||||
* PostCard 支持的操作类型
|
||||
*/
|
||||
export type PostCardActionType =
|
||||
| 'press' // 点击帖子主体
|
||||
| 'userPress' // 点击用户头像/名称
|
||||
| 'like' // 点赞
|
||||
| 'unlike' // 取消点赞
|
||||
| 'comment' // 评论
|
||||
| 'bookmark' // 收藏
|
||||
| 'unbookmark' // 取消收藏
|
||||
| 'share' // 分享
|
||||
| 'imagePress' // 点击图片
|
||||
| 'delete'; // 删除
|
||||
|
||||
/**
|
||||
* Action payload 类型
|
||||
*/
|
||||
export interface PostCardActionPayload {
|
||||
images?: ImageGridItem[];
|
||||
imageIndex?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 Action 对象
|
||||
*/
|
||||
export interface PostCardAction {
|
||||
type: PostCardActionType;
|
||||
payload?: PostCardActionPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的回调函数类型
|
||||
*/
|
||||
export type PostCardOnAction = (action: PostCardAction) => void;
|
||||
|
||||
// ==================== Features 配置类型 ====================
|
||||
|
||||
/**
|
||||
* 显示特性配置
|
||||
*/
|
||||
export interface PostCardFeatures {
|
||||
/** 显示用户信息 */
|
||||
showAuthor?: boolean;
|
||||
/** 显示标题 */
|
||||
showTitle?: boolean;
|
||||
/** 显示内容 */
|
||||
showContent?: boolean;
|
||||
/** 显示图片 */
|
||||
showImages?: boolean;
|
||||
/** 显示投票预览 */
|
||||
showVote?: boolean;
|
||||
/** 显示热门评论 */
|
||||
showTopComment?: boolean;
|
||||
/** 显示操作栏 */
|
||||
showActions?: boolean;
|
||||
/** 显示浏览数 */
|
||||
showViews?: boolean;
|
||||
/** 显示分享按钮 */
|
||||
showShare?: boolean;
|
||||
/** 显示收藏按钮 */
|
||||
showBookmark?: boolean;
|
||||
/** 显示删除按钮(需配合权限判断) */
|
||||
showDelete?: boolean;
|
||||
/** 显示置顶标签 */
|
||||
showPinned?: boolean;
|
||||
/** 显示徽章(楼主/管理员) */
|
||||
showBadges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Features 预设名称
|
||||
*/
|
||||
export type PostCardFeaturesPreset = 'full' | 'compact' | 'grid';
|
||||
|
||||
/**
|
||||
* Features 配置(可以是预设名称或自定义对象)
|
||||
*/
|
||||
export type PostCardFeaturesConfig = PostCardFeaturesPreset | PostCardFeatures;
|
||||
|
||||
// ==================== 主组件 Props ====================
|
||||
|
||||
/**
|
||||
* PostCard 主组件 Props
|
||||
*/
|
||||
export interface PostCardProps {
|
||||
/** 帖子数据 */
|
||||
post: Post;
|
||||
|
||||
/** 统一事件回调 */
|
||||
onAction: PostCardOnAction;
|
||||
|
||||
/** 显示模式:列表模式 | 网格模式 */
|
||||
variant?: 'list' | 'grid';
|
||||
|
||||
/** 预设配置名称,或自定义配置对象 */
|
||||
features?: PostCardFeaturesConfig;
|
||||
|
||||
/** 当前用户是否为帖子作者(用于显示删除按钮) */
|
||||
isPostAuthor?: boolean;
|
||||
|
||||
/** 当前用户是否为楼主(在帖子详情页显示"楼主"徽章) */
|
||||
isAuthor?: boolean;
|
||||
|
||||
/** 索引(用于虚拟化列表优化) */
|
||||
index?: number;
|
||||
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
// ==================== 子组件 Props ====================
|
||||
|
||||
/**
|
||||
* PostHeader 子组件 Props
|
||||
*/
|
||||
export interface PostHeaderProps {
|
||||
author: UserDTO;
|
||||
createdAt: string;
|
||||
editedAt?: string;
|
||||
isPinned?: boolean;
|
||||
badges?: Array<{ type: 'author' | 'admin'; label: string }>;
|
||||
onDelete?: () => void;
|
||||
onUserPress: () => void;
|
||||
showDelete?: boolean;
|
||||
showPinned?: boolean;
|
||||
showBadges?: boolean;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostTitle 子组件 Props
|
||||
*/
|
||||
export interface PostTitleProps {
|
||||
title?: string;
|
||||
numberOfLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostContent 子组件 Props
|
||||
*/
|
||||
export interface PostContentProps {
|
||||
content?: string;
|
||||
compact?: boolean;
|
||||
maxLines?: number;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostImages 子组件 Props
|
||||
*/
|
||||
export interface PostImagesProps {
|
||||
images: PostImageDTO[];
|
||||
displayMode: 'list' | 'grid';
|
||||
onImagePress: (images: ImageGridItem[], index: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostActions 子组件 Props
|
||||
*/
|
||||
export interface PostActionsProps {
|
||||
viewsCount: number;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
isLiked: boolean;
|
||||
isFavorited: boolean;
|
||||
onLike: () => void;
|
||||
onComment: () => void;
|
||||
onShare: () => void;
|
||||
onBookmark: () => void;
|
||||
showViews?: boolean;
|
||||
showShare?: boolean;
|
||||
showBookmark?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostVotePreview 子组件 Props
|
||||
*/
|
||||
export interface PostVotePreviewProps {
|
||||
isVote: boolean;
|
||||
totalVotes?: number;
|
||||
optionsCount?: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostTopComment 子组件 Props
|
||||
*/
|
||||
export interface PostTopCommentProps {
|
||||
comment: CommentDTO | null;
|
||||
totalComments: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostGridCover 子组件 Props
|
||||
*/
|
||||
export interface PostGridCoverProps {
|
||||
image: PostImageDTO | null;
|
||||
content?: string;
|
||||
isVote?: boolean;
|
||||
aspectRatio: number;
|
||||
onImagePress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostGridInfo 子组件 Props
|
||||
*/
|
||||
export interface PostGridInfoProps {
|
||||
title?: string;
|
||||
author: UserDTO;
|
||||
likesCount: number;
|
||||
onUserPress: () => void;
|
||||
}
|
||||
|
||||
// ==================== 容器组件 Props ====================
|
||||
|
||||
/**
|
||||
* PostCardList 容器组件 Props
|
||||
*/
|
||||
export interface PostCardListProps {
|
||||
post: Post;
|
||||
onAction: PostCardOnAction;
|
||||
features: PostCardFeatures;
|
||||
isPostAuthor?: boolean;
|
||||
isAuthor?: boolean;
|
||||
index?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCardGrid 容器组件 Props
|
||||
*/
|
||||
export interface PostCardGridProps {
|
||||
post: Post;
|
||||
onAction: PostCardOnAction;
|
||||
features: PostCardFeatures;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
// ==================== 工具类型 ====================
|
||||
|
||||
/**
|
||||
* 徽章类型
|
||||
*/
|
||||
export interface Badge {
|
||||
type: 'author' | 'admin';
|
||||
label: string;
|
||||
}
|
||||
246
src/components/business/QRCodeScanner.tsx
Normal file
246
src/components/business/QRCodeScanner.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Modal,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface QRCodeScannerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function createQrScannerStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
scanArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scanFrame: {
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
position: 'relative',
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 3,
|
||||
},
|
||||
topLeft: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
topRight: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
bottomLeft: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
bottomRight: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
scanText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
flashButton: {
|
||||
padding: spacing.md,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
permissionContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
permissionText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
permissionButton: {
|
||||
marginTop: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
permissionButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setScanned(false);
|
||||
if (!permission?.granted) {
|
||||
requestPermission();
|
||||
}
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
|
||||
if (scanned) return;
|
||||
setScanned(true);
|
||||
onClose();
|
||||
|
||||
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||
const sessionId = extractSessionId(data);
|
||||
if (sessionId) {
|
||||
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
||||
} else {
|
||||
Alert.alert('无效的二维码', '无法识别该二维码');
|
||||
}
|
||||
} else {
|
||||
Alert.alert('无效的二维码', '请扫描网页端的登录二维码');
|
||||
}
|
||||
};
|
||||
|
||||
const extractSessionId = (url: string): string | null => {
|
||||
try {
|
||||
const sessionIdMatch = url.match(/session_id=([^&]+)/);
|
||||
return sessionIdMatch ? sessionIdMatch[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission?.granted) {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ['qr'],
|
||||
}}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<View style={styles.scanArea}>
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={[styles.corner, styles.topLeft]} />
|
||||
<View style={[styles.corner, styles.topRight]} />
|
||||
<View style={[styles.corner, styles.bottomLeft]} />
|
||||
<View style={[styles.corner, styles.bottomRight]} />
|
||||
</View>
|
||||
<Text style={styles.scanText}>将二维码放入框内即可扫描</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.flashButton} onPress={() => {}}>
|
||||
<MaterialCommunityIcons name="flashlight" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCodeScanner;
|
||||
@@ -3,10 +3,10 @@
|
||||
* 用于搜索内容
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
@@ -18,6 +18,63 @@ interface SearchBarProps {
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function createSearchBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: colors.primary.main,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -27,6 +84,8 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -59,7 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
@@ -69,73 +127,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
style={styles.clearButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={14}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E7E7E7',
|
||||
// 减少常态阴影,避免看起来像"黑框"
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
// 焦点时使用更柔和的边框颜色
|
||||
borderColor: colors.primary.main,
|
||||
// 不添加额外的阴影,保持简洁
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchBar;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* SystemMessageItem 系统消息项组件
|
||||
* SystemMessageItem 系统消息项组件 - 美化版
|
||||
* 根据系统消息类型显示不同图标和内容
|
||||
* 参考 QQ 10000 系统消息和微信服务通知样式
|
||||
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
@@ -17,15 +17,16 @@ import Avatar from '../common/Avatar';
|
||||
interface SystemMessageItemProps {
|
||||
message: SystemMessageResponse;
|
||||
onPress?: () => void;
|
||||
onAvatarPress?: () => void; // 头像点击回调
|
||||
onAvatarPress?: () => void;
|
||||
onRequestAction?: (approve: boolean) => void;
|
||||
requestActionLoading?: boolean;
|
||||
index?: number; // 用于交错动画
|
||||
}
|
||||
|
||||
// 系统消息类型到图标和颜色的映射
|
||||
const getSystemMessageIcon = (
|
||||
systemType: SystemMessageType
|
||||
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => {
|
||||
systemType: SystemMessageType,
|
||||
colors: AppColors
|
||||
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
|
||||
switch (systemType) {
|
||||
case 'like_post':
|
||||
case 'like_comment':
|
||||
@@ -33,83 +34,243 @@ const getSystemMessageIcon = (
|
||||
case 'favorite_post':
|
||||
return {
|
||||
icon: 'heart',
|
||||
color: colors.error.main,
|
||||
bgColor: colors.error.light + '20',
|
||||
color: '#FF4757',
|
||||
bgColor: '#FFE4E6',
|
||||
gradient: ['#FF4757', '#FF6B7A'],
|
||||
};
|
||||
case 'comment':
|
||||
return {
|
||||
icon: 'comment',
|
||||
color: colors.info.main,
|
||||
bgColor: colors.info.light + '20',
|
||||
icon: 'comment-text',
|
||||
color: '#2196F3',
|
||||
bgColor: '#E3F2FD',
|
||||
gradient: ['#2196F3', '#64B5F6'],
|
||||
};
|
||||
case 'reply':
|
||||
return {
|
||||
icon: 'reply',
|
||||
color: colors.success.main,
|
||||
bgColor: colors.success.light + '20',
|
||||
color: '#4CAF50',
|
||||
bgColor: '#E8F5E9',
|
||||
gradient: ['#4CAF50', '#81C784'],
|
||||
};
|
||||
case 'follow':
|
||||
return {
|
||||
icon: 'account-plus',
|
||||
color: '#9C27B0', // 紫色
|
||||
bgColor: '#9C27B020',
|
||||
color: '#9C27B0',
|
||||
bgColor: '#F3E5F5',
|
||||
gradient: ['#9C27B0', '#BA68C8'],
|
||||
};
|
||||
case 'mention':
|
||||
return {
|
||||
icon: 'at',
|
||||
color: colors.warning.main,
|
||||
bgColor: colors.warning.light + '20',
|
||||
color: '#FF9800',
|
||||
bgColor: '#FFF3E0',
|
||||
gradient: ['#FF9800', '#FFB74D'],
|
||||
};
|
||||
case 'system':
|
||||
return {
|
||||
icon: 'cog',
|
||||
color: colors.text.secondary,
|
||||
bgColor: colors.background.disabled,
|
||||
color: '#607D8B',
|
||||
bgColor: '#ECEFF1',
|
||||
gradient: ['#607D8B', '#90A4AE'],
|
||||
};
|
||||
case 'announcement':
|
||||
case 'announce':
|
||||
return {
|
||||
icon: 'bullhorn',
|
||||
color: colors.primary.main,
|
||||
bgColor: colors.primary.light + '20',
|
||||
bgColor: colors.primary.light + '30',
|
||||
gradient: [colors.primary.main, colors.primary.light],
|
||||
};
|
||||
case 'group_invite':
|
||||
return {
|
||||
icon: 'account-multiple-plus',
|
||||
color: colors.primary.main,
|
||||
bgColor: colors.primary.light + '20',
|
||||
color: '#00BCD4',
|
||||
bgColor: '#E0F7FA',
|
||||
gradient: ['#00BCD4', '#4DD0E1'],
|
||||
};
|
||||
case 'group_join_apply':
|
||||
return {
|
||||
icon: 'account-clock',
|
||||
color: colors.warning.main,
|
||||
bgColor: colors.warning.light + '20',
|
||||
color: '#FF9800',
|
||||
bgColor: '#FFF3E0',
|
||||
gradient: ['#FF9800', '#FFB74D'],
|
||||
};
|
||||
case 'group_join_approved':
|
||||
return {
|
||||
icon: 'check-circle',
|
||||
color: colors.success.main,
|
||||
bgColor: colors.success.light + '20',
|
||||
color: '#4CAF50',
|
||||
bgColor: '#E8F5E9',
|
||||
gradient: ['#4CAF50', '#81C784'],
|
||||
};
|
||||
case 'group_join_rejected':
|
||||
return {
|
||||
icon: 'close-circle',
|
||||
color: colors.error.main,
|
||||
bgColor: colors.error.light + '20',
|
||||
color: '#F44336',
|
||||
bgColor: '#FFEBEE',
|
||||
gradient: ['#F44336', '#E57373'],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'bell',
|
||||
color: colors.text.secondary,
|
||||
bgColor: colors.background.disabled,
|
||||
color: '#757575',
|
||||
bgColor: '#F5F5F5',
|
||||
gradient: ['#757575', '#9E9E9E'],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function createSystemMessageStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
...shadows.sm,
|
||||
},
|
||||
unreadContainer: {
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
},
|
||||
unreadIndicator: {
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
top: '50%',
|
||||
marginTop: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
typeIconBadge: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
titleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
unreadTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
marginLeft: 2,
|
||||
},
|
||||
time: {
|
||||
fontSize: fontSizes.sm,
|
||||
flexShrink: 0,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: `${colors.error.main}55`,
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: `${colors.success.main}55`,
|
||||
backgroundColor: `${colors.success.main}18`,
|
||||
},
|
||||
actionBtnText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 获取系统消息标题
|
||||
const getSystemMessageTitle = (message: SystemMessageResponse): string => {
|
||||
const { system_type, extra_data } = message;
|
||||
// 兼容后端返回的 actor_name 和 operator_name
|
||||
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
|
||||
const groupName = extra_data?.group_name || extra_data?.target_title;
|
||||
|
||||
@@ -163,7 +324,11 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
onAvatarPress,
|
||||
onRequestAction,
|
||||
requestActionLoading = false,
|
||||
index = 0,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
@@ -176,10 +341,9 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type);
|
||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
|
||||
const title = getSystemMessageTitle(message);
|
||||
const { extra_data } = message;
|
||||
// 兼容后端返回的 actor_name 和 operator_name
|
||||
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
|
||||
const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar;
|
||||
const groupAvatar = extra_data?.group_avatar;
|
||||
@@ -188,99 +352,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
|
||||
requestStatus === 'pending' &&
|
||||
!!onRequestAction;
|
||||
|
||||
// 使用固定的响应式值,兼容移动端和Web端
|
||||
const containerPadding = spacing.md;
|
||||
const avatarSize = 44;
|
||||
const iconSize = 22;
|
||||
const contentFontSize = 14;
|
||||
const titleFontSize = 14;
|
||||
const timeFontSize = 12;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: containerPadding,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: avatarSize / 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
fontSize: titleFontSize,
|
||||
},
|
||||
time: {
|
||||
fontSize: timeFontSize,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: contentFontSize,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: colors.error.light,
|
||||
backgroundColor: colors.error.light + '18',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: colors.success.light,
|
||||
backgroundColor: colors.success.light + '18',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
width: 24,
|
||||
},
|
||||
});
|
||||
const isUnread = message.is_read !== true;
|
||||
|
||||
// 判断是否显示操作者头像
|
||||
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
|
||||
@@ -288,31 +360,68 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
);
|
||||
const showGroupAvatar = message.system_type === 'group_invite';
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusBadge = () => {
|
||||
if (requestStatus === 'accepted') {
|
||||
return (
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${colors.success.main}22` }]}>
|
||||
<MaterialCommunityIcons name="check" size={10} color={colors.success.main} />
|
||||
<Text variant="caption" color={colors.success.main} style={styles.statusText}>已同意</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (requestStatus === 'rejected') {
|
||||
return (
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${colors.error.main}22` }]}>
|
||||
<MaterialCommunityIcons name="close" size={10} color={colors.error.main} />
|
||||
<Text variant="caption" color={colors.error.main} style={styles.statusText}>已拒绝</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
style={[
|
||||
styles.container,
|
||||
isUnread && styles.unreadContainer,
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
activeOpacity={onPress ? 0.85 : 1}
|
||||
disabled={!onPress}
|
||||
>
|
||||
{/* 左侧未读指示器 */}
|
||||
{isUnread && <View style={styles.unreadIndicator} />}
|
||||
|
||||
{/* 图标/头像区域 */}
|
||||
<View style={styles.iconContainer}>
|
||||
{showGroupAvatar ? (
|
||||
<Avatar
|
||||
source={groupAvatar || ''}
|
||||
size={avatarSize}
|
||||
name={extra_data?.group_name || '群聊'}
|
||||
/>
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
source={groupAvatar || ''}
|
||||
size={48}
|
||||
name={extra_data?.group_name || '群聊'}
|
||||
/>
|
||||
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
|
||||
<MaterialCommunityIcons name={icon} size={12} color={color} />
|
||||
</View>
|
||||
</View>
|
||||
) : showOperatorAvatar ? (
|
||||
<Avatar
|
||||
source={operatorAvatar || ''}
|
||||
size={avatarSize}
|
||||
name={operatorName}
|
||||
onPress={onAvatarPress}
|
||||
/>
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
source={operatorAvatar || ''}
|
||||
size={48}
|
||||
name={operatorName}
|
||||
onPress={onAvatarPress}
|
||||
/>
|
||||
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
|
||||
<MaterialCommunityIcons name={icon} size={12} color={color} />
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
|
||||
<MaterialCommunityIcons name={icon} size={iconSize} color={color} />
|
||||
<MaterialCommunityIcons name={icon} size={24} color={color} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -321,36 +430,49 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
<View style={styles.content}>
|
||||
{/* 标题行 */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text variant="body" style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.time}>
|
||||
<View style={styles.titleLeft}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[styles.title, ...(isUnread ? [styles.unreadTitle] : [])]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{getStatusBadge()}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.time}>
|
||||
{formatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.messageContent}>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
numberOfLines={2}
|
||||
style={styles.messageContent}
|
||||
>
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
{/* 附加信息 - 优先显示 target_title,图标根据 target_type 区分 */}
|
||||
{/* 附加信息 */}
|
||||
{(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) &&
|
||||
!['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) &&
|
||||
(() => {
|
||||
const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
|
||||
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
|
||||
const iconName = isCommentType ? 'comment-outline' : 'file-document-outline';
|
||||
return (
|
||||
<View style={styles.extraInfo}>
|
||||
<MaterialCommunityIcons name={iconName} size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.extraText}>
|
||||
{previewText}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
|
||||
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
|
||||
const iconName = isCommentType ? 'comment-text-outline' : 'file-document-outline';
|
||||
return (
|
||||
<View style={styles.extraInfo}>
|
||||
<MaterialCommunityIcons name={iconName} size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} numberOfLines={1} style={styles.extraText}>
|
||||
{previewText}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{isActionable && (
|
||||
<View style={styles.actionsRow}>
|
||||
<TouchableOpacity
|
||||
@@ -358,20 +480,22 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
onPress={() => onRequestAction?.(false)}
|
||||
disabled={requestActionLoading}
|
||||
>
|
||||
<Text variant="caption" color={colors.error.main}>拒绝</Text>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.error.main} />
|
||||
<Text variant="caption" color={colors.error.main} style={styles.actionBtnText}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.approveBtn]}
|
||||
onPress={() => onRequestAction?.(true)}
|
||||
disabled={requestActionLoading}
|
||||
>
|
||||
<Text variant="caption" color={colors.success.main}>同意</Text>
|
||||
<MaterialCommunityIcons name="check" size={14} color={colors.success.main} />
|
||||
<Text variant="caption" color={colors.success.main} style={styles.actionBtnText}>同意</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 右侧箭头(如果有跳转) */}
|
||||
{/* 右侧箭头 */}
|
||||
{onPress && (
|
||||
<View style={styles.arrowContainer}>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 新增胶囊式、分段式等现代设计风格
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';
|
||||
import React, { ReactNode, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
||||
@@ -20,6 +20,166 @@ interface TabBarProps {
|
||||
rightContent?: ReactNode;
|
||||
variant?: TabBarVariant;
|
||||
icons?: string[];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
function createTabBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
scrollableContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
flex: 1,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
activeTab: {},
|
||||
tabText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
rightContent: {
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
pillContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pillTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
pillTabActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
pillTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.xs,
|
||||
marginHorizontal: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
segmentedTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
segmentedTabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
segmentedTabFirst: {
|
||||
borderTopLeftRadius: borderRadius.md,
|
||||
borderBottomLeftRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabLast: {
|
||||
borderTopRightRadius: borderRadius.md,
|
||||
borderBottomRightRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentedTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
position: 'relative',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modernTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernTabText: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const TabBar: React.FC<TabBarProps> = ({
|
||||
@@ -30,7 +190,11 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
rightContent,
|
||||
variant = 'default',
|
||||
icons,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
|
||||
|
||||
const renderTabs = () => {
|
||||
return tabs.map((tab, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
@@ -109,11 +273,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
style={styles.segmentedTabIcon}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={styles.segmentedTabText}
|
||||
>
|
||||
<Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
|
||||
{tab}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -121,7 +281,6 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// default variant
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
@@ -157,12 +316,8 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
|
||||
if (scrollable) {
|
||||
return (
|
||||
<View style={getContainerStyle()}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollableContainer}
|
||||
>
|
||||
<View style={[getContainerStyle(), style]}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
|
||||
{renderTabs()}
|
||||
</ScrollView>
|
||||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||||
@@ -171,177 +326,11 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={getContainerStyle()}>
|
||||
<View style={[getContainerStyle(), style]}>
|
||||
{renderTabs()}
|
||||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Default variant
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
scrollableContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
flex: 1,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
activeTab: {
|
||||
// 激活状态样式
|
||||
},
|
||||
tabText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
rightContent: {
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
|
||||
// Pill variant
|
||||
pillContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pillTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
pillTabActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
pillTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Segmented variant
|
||||
segmentedContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.xs,
|
||||
marginHorizontal: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
segmentedTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
segmentedTabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
segmentedTabFirst: {
|
||||
borderTopLeftRadius: borderRadius.md,
|
||||
borderBottomLeftRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabLast: {
|
||||
borderTopRightRadius: borderRadius.md,
|
||||
borderBottomRightRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentedTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
|
||||
// Modern variant - 现代化标签栏
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
position: 'relative',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15', // 10% opacity
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modernTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernTabText: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
});
|
||||
|
||||
export default TabBar;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 在宽屏下显示更大的头像和封面
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Image,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Button from '../common/Button';
|
||||
@@ -49,7 +49,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
onFollowersPress,
|
||||
onAvatarPress,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
|
||||
// 格式化数字
|
||||
@@ -236,22 +237,22 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
|
||||
{/* 个人信息标签 */}
|
||||
<View style={styles.metaInfo}>
|
||||
{user.location && (
|
||||
{user.location?.trim() ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||
{user.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{user.website && (
|
||||
) : null}
|
||||
{user.website?.trim() ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||
{user.website.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||
@@ -317,7 +318,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
@@ -533,7 +535,8 @@ const styles = StyleSheet.create({
|
||||
alignSelf: 'center',
|
||||
padding: spacing.sm,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 React.memo 避免不必要的重新渲染
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 风格与现代整体UI保持一致
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { VoteOptionDTO } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
|
||||
@@ -28,6 +28,146 @@ interface VoteCardProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function createVoteCardStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
containerCompact: {
|
||||
padding: spacing.sm,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
optionButtonVoted: {
|
||||
borderColor: colors.primary.main,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
optionIndicator: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
marginRight: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionIndicatorVoted: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
optionText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionTextCompact: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 40,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
percentage: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
percentageVoted: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
footerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
unvoteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
unvoteText: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.background.paper + 'CC',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VoteCard: React.FC<VoteCardProps> = ({
|
||||
options,
|
||||
totalVotes,
|
||||
@@ -38,7 +178,8 @@ const VoteCard: React.FC<VoteCardProps> = ({
|
||||
isLoading = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 动画值
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
|
||||
const progressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -151,7 +292,7 @@ const VoteCard: React.FC<VoteCardProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
|
||||
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
|
||||
|
||||
// 排序后的选项(已投票的排在前面)
|
||||
const sortedOptions = React.useMemo(() => {
|
||||
@@ -229,142 +370,4 @@ const VoteCard: React.FC<VoteCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
containerCompact: {
|
||||
padding: spacing.sm,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
optionButtonVoted: {
|
||||
borderColor: colors.primary.main,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
optionIndicator: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
marginRight: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionIndicatorVoted: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
optionText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionTextCompact: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 40,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
percentage: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
percentageVoted: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
footerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
unvoteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
unvoteText: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.background.paper + 'CC',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteCard;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 用于创建帖子时编辑投票选项
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VoteEditorProps {
|
||||
@@ -24,6 +24,86 @@ interface VoteEditorProps {
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
function createVoteEditorStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionIndex: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.disabled,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionInput: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
height: 44,
|
||||
},
|
||||
removeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
removeButtonPlaceholder: {
|
||||
width: 28,
|
||||
},
|
||||
addOptionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addOptionText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
hintContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
options,
|
||||
onAddOption,
|
||||
@@ -33,6 +113,8 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
minOptions = 2,
|
||||
maxLength = 50,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createVoteEditorStyles(colors), [colors]);
|
||||
const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
|
||||
const canAddOption = options.length < maxOptions;
|
||||
const canRemoveOption = options.length > minOptions;
|
||||
@@ -122,82 +204,4 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionIndex: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.disabled,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionInput: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
height: 44,
|
||||
},
|
||||
removeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
removeButtonPlaceholder: {
|
||||
width: 28,
|
||||
},
|
||||
addOptionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addOptionText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
hintContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteEditor;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
* 用于帖子列表中显示投票预览,类似微博风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VotePreviewProps {
|
||||
@@ -19,11 +19,51 @@ interface VotePreviewProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
function createVotePreviewStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '30',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VotePreview: React.FC<VotePreviewProps> = ({
|
||||
totalVotes = 0,
|
||||
optionsCount = 0,
|
||||
onPress,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createVotePreviewStyles(colors), [colors]);
|
||||
|
||||
// 格式化票数
|
||||
const formatVoteCount = (count: number): string => {
|
||||
if (count >= 10000) {
|
||||
@@ -71,39 +111,4 @@ const VotePreview: React.FC<VotePreviewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '30',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default VotePreview;
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
* 业务组件导出
|
||||
*/
|
||||
|
||||
// PostCard 新架构(从 PostCard 目录导出)
|
||||
export { default as PostCard } from './PostCard';
|
||||
// 导出 PostCard 相关类型和工具
|
||||
export type {
|
||||
PostCardActionType,
|
||||
PostCardAction,
|
||||
PostCardOnAction,
|
||||
PostCardFeatures,
|
||||
PostCardFeaturesPreset,
|
||||
PostCardFeaturesConfig,
|
||||
PostCardProps,
|
||||
} from './PostCard';
|
||||
export { default as CommentItem } from './CommentItem';
|
||||
export { default as UserProfileHeader } from './UserProfileHeader';
|
||||
export { default as NotificationItem } from './NotificationItem';
|
||||
export { default as SystemMessageItem } from './SystemMessageItem';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
export { default as TabBar } from './TabBar';
|
||||
|
||||
58
src/components/common/AppBackButton.tsx
Normal file
58
src/components/common/AppBackButton.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { borderRadius, spacing, useAppColors } from '../../theme';
|
||||
|
||||
type AppBackButtonIcon = 'arrow-left' | 'close';
|
||||
|
||||
interface AppBackButtonProps {
|
||||
onPress: () => void;
|
||||
icon?: AppBackButtonIcon;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
iconColor?: string;
|
||||
backgroundColor?: string;
|
||||
hitSlop?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const AppBackButton: React.FC<AppBackButtonProps> = ({
|
||||
onPress,
|
||||
icon = 'arrow-left',
|
||||
style,
|
||||
iconColor: iconColorProp,
|
||||
backgroundColor: backgroundColorProp,
|
||||
hitSlop = 8,
|
||||
testID,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const iconColor = iconColorProp ?? colors.text.primary;
|
||||
const backgroundColor = backgroundColorProp ?? colors.background.paper;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.button, { backgroundColor }, style]}
|
||||
activeOpacity={0.75}
|
||||
hitSlop={hitSlop}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
|
||||
>
|
||||
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppBackButton;
|
||||
@@ -5,10 +5,98 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
function createDialogHostStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.36)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
iconHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
marginTop: spacing.md,
|
||||
color: colors.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}28`,
|
||||
backgroundColor: colors.background.paper,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
borderColor: `${colors.error.main}40`,
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryText: {
|
||||
color: colors.text.inverse,
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
destructiveText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const AppDialogHost: React.FC = () => {
|
||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createDialogHostStyles(colors), [colors]);
|
||||
|
||||
useEffect(() => {
|
||||
const unbind = bindDialogListener((payload) => {
|
||||
@@ -19,7 +107,7 @@ const AppDialogHost: React.FC = () => {
|
||||
|
||||
const actions = useMemo<AlertButton[]>(() => {
|
||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||
return dialog.actions.slice(0, 3);
|
||||
return dialog.actions;
|
||||
}, [dialog]);
|
||||
|
||||
const onClose = () => {
|
||||
@@ -101,88 +189,4 @@ const AppDialogHost: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.36)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
iconHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
marginTop: spacing.md,
|
||||
color: colors.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}28`,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: '#FEECEC',
|
||||
borderColor: '#FCD4D1',
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
destructiveText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppDialogHost;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface PromptState extends PromptPayload {
|
||||
id: number;
|
||||
@@ -12,29 +12,96 @@ interface PromptState extends PromptPayload {
|
||||
|
||||
const DEFAULT_DURATION = 2200;
|
||||
|
||||
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = {
|
||||
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' },
|
||||
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' },
|
||||
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' },
|
||||
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' },
|
||||
};
|
||||
|
||||
const iconColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.dark,
|
||||
error: colors.error.main,
|
||||
};
|
||||
|
||||
const accentColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.main,
|
||||
error: colors.error.main,
|
||||
};
|
||||
function createPromptBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 9999,
|
||||
},
|
||||
card: {
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}22`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
accentBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
textWrap: {
|
||||
flex: 1,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const AppPromptBar: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createPromptBarStyles(colors), [colors]);
|
||||
|
||||
const styleMap = useMemo<
|
||||
Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }>
|
||||
>(
|
||||
() => ({
|
||||
info: { backgroundColor: colors.background.paper, icon: 'information-outline' },
|
||||
success: { backgroundColor: colors.background.paper, icon: 'check-circle-outline' },
|
||||
warning: { backgroundColor: colors.background.paper, icon: 'alert-outline' },
|
||||
error: { backgroundColor: colors.background.paper, icon: 'alert-circle-outline' },
|
||||
}),
|
||||
[colors.background.paper],
|
||||
);
|
||||
|
||||
const iconColorMap = useMemo(
|
||||
() => ({
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.dark,
|
||||
error: colors.error.main,
|
||||
}),
|
||||
[colors.primary.main, colors.success.main, colors.warning.dark, colors.error.main],
|
||||
);
|
||||
|
||||
const accentColorMap = useMemo(
|
||||
() => ({
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.main,
|
||||
error: colors.error.main,
|
||||
}),
|
||||
[colors.primary.main, colors.success.main, colors.warning.main, colors.error.main],
|
||||
);
|
||||
|
||||
const [prompt, setPrompt] = useState<PromptState | null>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animation = useRef(new Animated.Value(0)).current;
|
||||
@@ -131,55 +198,4 @@ const AppPromptBar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 9999,
|
||||
},
|
||||
card: {
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}22`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
accentBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
textWrap: {
|
||||
flex: 1,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppPromptBar;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
import { borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type AvatarSource = string | { uri: string } | number | null;
|
||||
@@ -27,29 +27,50 @@ interface AvatarProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createAvatarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({
|
||||
source,
|
||||
size = 40,
|
||||
name,
|
||||
onPress,
|
||||
showBadge = false,
|
||||
badgeColor = colors.success.main,
|
||||
badgeColor,
|
||||
style,
|
||||
}) => {
|
||||
// 获取首字母
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createAvatarStyles(colors), [colors]);
|
||||
const resolvedBadgeColor = badgeColor ?? colors.success.main;
|
||||
|
||||
const getInitial = (): string => {
|
||||
if (!name) return '?';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
// 中文字符
|
||||
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
|
||||
return firstChar;
|
||||
}
|
||||
return firstChar;
|
||||
};
|
||||
|
||||
// 渲染头像内容
|
||||
const renderAvatarContent = () => {
|
||||
// 如果有图片源
|
||||
if (source) {
|
||||
const imageSource =
|
||||
typeof source === 'string' ? { uri: source } : source;
|
||||
@@ -72,7 +93,6 @@ const Avatar: React.FC<AvatarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 显示首字母
|
||||
const fontSize = size / 2;
|
||||
return (
|
||||
<View
|
||||
@@ -100,7 +120,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: badgeColor,
|
||||
backgroundColor: resolvedBadgeColor,
|
||||
width: size * 0.3,
|
||||
height: size * 0.3,
|
||||
borderRadius: (size * 0.3) / 2,
|
||||
@@ -124,23 +144,4 @@ const Avatar: React.FC<AvatarProps> = ({
|
||||
return avatarContainer;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
});
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 支持多种变体、尺寸、加载状态和图标
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme';
|
||||
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
|
||||
@@ -27,12 +27,86 @@ interface ButtonProps {
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: string; // MaterialCommunityIcons name
|
||||
icon?: string;
|
||||
iconPosition?: IconPosition;
|
||||
fullWidth?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createButtonStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
size_sm: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 32,
|
||||
},
|
||||
size_md: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 40,
|
||||
},
|
||||
size_lg: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
minHeight: 48,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.secondary.main,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
elevation: 0,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
disabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
borderColor: colors.background.disabled,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
textBase: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
textSize_sm: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
textSize_md: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
textSize_lg: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
iconLeft: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
iconRight: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
@@ -45,11 +119,12 @@ const Button: React.FC<ButtonProps> = ({
|
||||
fullWidth = false,
|
||||
style,
|
||||
}) => {
|
||||
// 获取按钮样式
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createButtonStyles(colors), [colors]);
|
||||
|
||||
const getButtonStyle = (): ViewStyle[] => {
|
||||
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
|
||||
|
||||
// 变体样式
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
baseStyle.push(styles.primary);
|
||||
@@ -68,12 +143,10 @@ const Button: React.FC<ButtonProps> = ({
|
||||
break;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
if (disabled || loading) {
|
||||
baseStyle.push(styles.disabled);
|
||||
}
|
||||
|
||||
// 全宽度
|
||||
if (fullWidth) {
|
||||
baseStyle.push(styles.fullWidth);
|
||||
}
|
||||
@@ -81,7 +154,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取文本样式
|
||||
const getTextStyle = (): TextStyle => {
|
||||
const baseStyle: TextStyle = {
|
||||
...styles.textBase,
|
||||
@@ -109,7 +181,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取图标大小
|
||||
const getIconSize = (): number => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
@@ -121,7 +192,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图标颜色
|
||||
const getIconColor = (): string => {
|
||||
if (disabled || loading) {
|
||||
return colors.text.disabled;
|
||||
@@ -150,9 +220,9 @@ const Button: React.FC<ButtonProps> = ({
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'outline' || variant === 'text'
|
||||
? colors.primary.main
|
||||
: colors.text.inverse}
|
||||
color={
|
||||
variant === 'outline' || variant === 'text' ? colors.primary.main : colors.text.inverse
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
@@ -179,82 +249,4 @@ const Button: React.FC<ButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 尺寸样式
|
||||
size_sm: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 32,
|
||||
},
|
||||
size_md: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 40,
|
||||
},
|
||||
size_lg: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
minHeight: 48,
|
||||
},
|
||||
// 变体样式
|
||||
primary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.secondary.main,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
elevation: 0,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
// 禁用状态
|
||||
disabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
borderColor: colors.background.disabled,
|
||||
},
|
||||
// 全宽度
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
// 文本样式
|
||||
textBase: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
textSize_sm: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
textSize_md: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
textSize_lg: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
// 图标样式
|
||||
iconLeft: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
iconRight: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* 白色背景、圆角、阴影,支持点击
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, borderRadius, spacing, shadows } from '../../theme';
|
||||
import { borderRadius, spacing, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
type ShadowSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
@@ -13,10 +13,20 @@ interface CardProps {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
padding?: number;
|
||||
shadow?: ShadowSize;
|
||||
shadow?: ShadowSize | 'none';
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createCardStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
@@ -24,13 +34,11 @@ const Card: React.FC<CardProps> = ({
|
||||
shadow = 'none',
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCardStyles(colors), [colors]);
|
||||
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
|
||||
|
||||
const cardStyle = [
|
||||
styles.card,
|
||||
{ padding },
|
||||
shadowStyle,
|
||||
].filter(Boolean);
|
||||
const cardStyle = [styles.card, { padding }, shadowStyle].filter(Boolean);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
@@ -47,12 +55,4 @@ const Card: React.FC<CardProps> = ({
|
||||
return <View style={[...cardStyle, style]}>{children}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default Card;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { spacing, useAppColors } from '../../theme';
|
||||
|
||||
interface DividerProps {
|
||||
margin?: number;
|
||||
@@ -13,16 +13,14 @@ interface DividerProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Divider: React.FC<DividerProps> = ({
|
||||
margin = spacing.lg,
|
||||
color = colors.divider,
|
||||
style,
|
||||
}) => {
|
||||
const Divider: React.FC<DividerProps> = ({ margin = spacing.lg, color, style }) => {
|
||||
const colors = useAppColors();
|
||||
const lineColor = color ?? colors.divider;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.divider,
|
||||
{ marginVertical: margin, backgroundColor: color },
|
||||
{ marginVertical: margin, backgroundColor: lineColor },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* 显示空数据时的占位界面,采用现代插图风格设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
import Button from './Button';
|
||||
|
||||
@@ -20,6 +20,109 @@ interface EmptyStateProps {
|
||||
variant?: 'default' | 'modern' | 'compact';
|
||||
}
|
||||
|
||||
function createEmptyStateStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
minWidth: 120,
|
||||
},
|
||||
modernContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
minHeight: 280,
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
iconBackground: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
modernIcon: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
top: 5,
|
||||
right: 10,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main + '20',
|
||||
bottom: 15,
|
||||
left: 5,
|
||||
},
|
||||
modernTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
modernDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
maxWidth: 280,
|
||||
},
|
||||
modernButton: {
|
||||
minWidth: 140,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
compactIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon = 'folder-open-outline',
|
||||
title,
|
||||
@@ -29,7 +132,9 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
style,
|
||||
variant = 'modern',
|
||||
}) => {
|
||||
// 现代风格空状态
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createEmptyStateStyles(colors), [colors]);
|
||||
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<View style={[styles.modernContainer, style]}>
|
||||
@@ -45,19 +150,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
</View>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.primary}
|
||||
style={styles.modernTitle}
|
||||
>
|
||||
<Text variant="h3" color={colors.text.primary} style={styles.modernTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.modernDescription}
|
||||
>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.modernDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -74,7 +171,6 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑风格
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<View style={[styles.compactContainer, style]}>
|
||||
@@ -84,18 +180,13 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
color={colors.text.disabled}
|
||||
style={styles.compactIcon}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.compactTitle}
|
||||
>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.compactTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认风格
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<MaterialCommunityIcons
|
||||
@@ -104,19 +195,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
color={colors.text.disabled}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.secondary}
|
||||
style={styles.title}
|
||||
>
|
||||
<Text variant="h3" color={colors.text.secondary} style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.description}
|
||||
>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.description}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -133,110 +216,4 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// 默认风格
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
minWidth: 120,
|
||||
},
|
||||
|
||||
// 现代风格
|
||||
modernContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
minHeight: 280,
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
iconBackground: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
modernIcon: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
top: 5,
|
||||
right: 10,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main + '20',
|
||||
bottom: 15,
|
||||
left: 5,
|
||||
},
|
||||
modernTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
modernDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
maxWidth: 280,
|
||||
},
|
||||
modernButton: {
|
||||
minWidth: 140,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
|
||||
// 紧凑风格
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
compactIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmptyState;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 使用 expo-image,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StatusBar,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
@@ -25,16 +24,14 @@ import {
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
runOnJS,
|
||||
withTiming,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -84,9 +81,13 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
onSave,
|
||||
backgroundOpacity = 1,
|
||||
}) => {
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isClosingRef = useRef(false);
|
||||
const currentIndexRef = useRef(initialIndex);
|
||||
const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
currentIndexRef.current = currentIndex;
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null);
|
||||
@@ -127,7 +128,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
if (visible) {
|
||||
setCurrentIndex(initialIndex);
|
||||
setShowControls(true);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
resetZoom();
|
||||
StatusBar.setHidden(true, 'fade');
|
||||
@@ -136,37 +136,61 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
}
|
||||
}, [visible, initialIndex, resetZoom]);
|
||||
|
||||
// 图片变化时重置加载状态和缩放
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
if (visible) {
|
||||
isClosingRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 图片变化时重置缩放(加载态在 updateIndex / 打开弹窗时同步设置,避免晚一帧仍显示上一张)
|
||||
useEffect(() => {
|
||||
resetZoom();
|
||||
}, [currentImage?.id, resetZoom]);
|
||||
|
||||
const updateIndex = useCallback(
|
||||
(newIndex: number) => {
|
||||
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
|
||||
if (clampedIndex === currentIndexRef.current) {
|
||||
return;
|
||||
}
|
||||
setError(false);
|
||||
setCurrentIndex(clampedIndex);
|
||||
onIndexChange?.(clampedIndex);
|
||||
},
|
||||
[validImages.length, onIndexChange]
|
||||
);
|
||||
|
||||
const updateIndexFromSwipe = useCallback(
|
||||
(newIndex: number, direction: -1 | 1) => {
|
||||
pendingSwipeDirectionRef.current = direction;
|
||||
updateIndex(newIndex);
|
||||
},
|
||||
[updateIndex]
|
||||
);
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
setShowControls(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
updateIndex(currentIndex - 1);
|
||||
const requestClose = useCallback(() => {
|
||||
if (isClosingRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [currentIndex, updateIndex]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < validImages.length - 1) {
|
||||
updateIndex(currentIndex + 1);
|
||||
}
|
||||
}, [currentIndex, validImages.length, updateIndex]);
|
||||
isClosingRef.current = true;
|
||||
// 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
onClose();
|
||||
}, 120);
|
||||
}, [onClose]);
|
||||
|
||||
// 显示短暂提示
|
||||
const showToast = useCallback((type: 'success' | 'error') => {
|
||||
@@ -236,6 +260,30 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
// 滑动切换图片相关状态
|
||||
const swipeTranslateX = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
pendingSwipeDirectionRef.current = null;
|
||||
swipeTranslateX.value = 0;
|
||||
}, [currentIndex, swipeTranslateX]);
|
||||
|
||||
const switchWithDirection = useCallback(
|
||||
(targetIndex: number, direction: -1 | 1) => {
|
||||
updateIndexFromSwipe(targetIndex, direction);
|
||||
},
|
||||
[updateIndexFromSwipe]
|
||||
);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
switchWithDirection(currentIndex - 1, 1);
|
||||
}
|
||||
}, [currentIndex, switchWithDirection]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < validImages.length - 1) {
|
||||
switchWithDirection(currentIndex + 1, -1);
|
||||
}
|
||||
}, [currentIndex, validImages.length, switchWithDirection]);
|
||||
|
||||
// 统一的滑动手势:放大时拖动,未放大时切换图片
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突
|
||||
@@ -248,16 +296,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
// 放大状态下:拖动图片
|
||||
translateX.value = savedTranslateX.value + e.translationX;
|
||||
translateY.value = savedTranslateY.value + e.translationY;
|
||||
} else if (validImages.length > 1) {
|
||||
// 未放大且有多张图片:切换图片的跟随效果
|
||||
const isFirst = currentIndex === 0 && e.translationX > 0;
|
||||
const isLast = currentIndex === validImages.length - 1 && e.translationX < 0;
|
||||
if (isFirst || isLast) {
|
||||
// 边界阻力效果
|
||||
swipeTranslateX.value = e.translationX * 0.3;
|
||||
} else {
|
||||
swipeTranslateX.value = e.translationX;
|
||||
}
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
@@ -275,19 +313,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
|
||||
if (shouldGoNext && currentIndex < validImages.length - 1) {
|
||||
// 向左滑动,显示下一张
|
||||
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
|
||||
runOnJS(updateIndex)(currentIndex + 1);
|
||||
swipeTranslateX.value = 0;
|
||||
});
|
||||
runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1);
|
||||
} else if (shouldGoPrev && currentIndex > 0) {
|
||||
// 向右滑动,显示上一张
|
||||
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
|
||||
runOnJS(updateIndex)(currentIndex - 1);
|
||||
swipeTranslateX.value = 0;
|
||||
});
|
||||
} else {
|
||||
// 回弹到原位
|
||||
swipeTranslateX.value = withTiming(0, { duration: 200 });
|
||||
runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -297,7 +326,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
.numberOfTaps(1)
|
||||
.maxDistance(10)
|
||||
.onEnd(() => {
|
||||
runOnJS(onClose)();
|
||||
runOnJS(requestClose)();
|
||||
});
|
||||
|
||||
// 组合手势:
|
||||
@@ -326,7 +355,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
onRequestClose={requestClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
@@ -334,7 +363,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
{/* 顶部控制栏 */}
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={requestClose}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -351,7 +380,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
<MaterialCommunityIcons name="loading" size={24} color="#FFF" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
|
||||
)}
|
||||
@@ -363,12 +392,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
{/* 图片显示区域 */}
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<View style={styles.imageContainer}>
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="image-off" size={48} color="#999" />
|
||||
@@ -384,15 +407,13 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
contentFit="contain"
|
||||
cachePolicy="disk"
|
||||
priority="high"
|
||||
recyclingKey={currentImage.id}
|
||||
recyclingKey={currentImage.url}
|
||||
transition={null}
|
||||
allowDownscaling
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
}}
|
||||
/>
|
||||
@@ -523,16 +544,11 @@ const styles = StyleSheet.create({
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT * 0.8,
|
||||
},
|
||||
loadingContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 5,
|
||||
},
|
||||
errorContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
errorText: {
|
||||
color: '#999',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 支持预览图优化
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useCallback, useState, useEffect, createContext, useContext } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
Text,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SmartImage, ImageSource } from './SmartImage';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
import { SmartImage } from './SmartImage';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
@@ -40,6 +40,8 @@ export interface ImageGridItem {
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
preview_url_large?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
@@ -99,6 +101,89 @@ const calculateGridDimensions = (
|
||||
};
|
||||
};
|
||||
|
||||
function createImageGridStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
fullSize: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
singleContainer: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
horizontalContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
horizontalItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
gridItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '49%',
|
||||
},
|
||||
gridItem3: {
|
||||
width: '32.5%',
|
||||
},
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
masonryItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
moreText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '500',
|
||||
},
|
||||
compactContainer: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
compactItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ImageGridStyles = ReturnType<typeof createImageGridStyles>;
|
||||
const ImageGridStylesContext = createContext<ImageGridStyles | null>(null);
|
||||
|
||||
function useImageGridStyles(): ImageGridStyles {
|
||||
const ctx = useContext(ImageGridStylesContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useImageGridStyles must be used within ImageGrid or CompactImageGrid');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
|
||||
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
|
||||
|
||||
@@ -108,6 +193,8 @@ interface SingleImageItemProps {
|
||||
maxHeight: number;
|
||||
borderRadiusValue: number;
|
||||
onPress: () => void;
|
||||
usePreview: boolean;
|
||||
displayMode: ImageDisplayMode;
|
||||
}
|
||||
|
||||
const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
@@ -116,15 +203,27 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
maxHeight,
|
||||
borderRadiusValue,
|
||||
onPress,
|
||||
usePreview,
|
||||
displayMode,
|
||||
}) => {
|
||||
const styles = useImageGridStyles();
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const uri = image.uri || image.url || '';
|
||||
const mainUri = image.uri || image.url || '';
|
||||
const thumbnailUri =
|
||||
image.thumbnail_url ||
|
||||
image.thumbnailUrl ||
|
||||
image.preview_url ||
|
||||
'';
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) return;
|
||||
if (image.width && image.height) {
|
||||
setAspectRatio(image.width / image.height);
|
||||
return;
|
||||
}
|
||||
const probeUri = thumbnailUri || mainUri;
|
||||
if (!probeUri) return;
|
||||
let cancelled = false;
|
||||
|
||||
// 添加超时处理,防止高分辨率图片加载卡住
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!cancelled && aspectRatio === null) {
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
@@ -132,7 +231,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
}, 3000);
|
||||
|
||||
Image.getSize(
|
||||
uri,
|
||||
probeUri,
|
||||
(w, h) => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -150,7 +249,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [uri]);
|
||||
}, [image.width, image.height, thumbnailUri, mainUri]);
|
||||
|
||||
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
|
||||
|
||||
@@ -189,13 +288,19 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
|
||||
}
|
||||
|
||||
const previewForSmart =
|
||||
usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : '';
|
||||
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri }}
|
||||
source={{ uri: mainUri }}
|
||||
previewUrl={useSmartPreview ? previewForSmart : undefined}
|
||||
usePreview={useSmartPreview}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
@@ -224,7 +329,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
displayMode = 'list',
|
||||
usePreview = true,
|
||||
}) => {
|
||||
// 通过 onLayout 拿到容器实际宽度
|
||||
const colors = useAppColors();
|
||||
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// 过滤有效图片 - 支持 uri 或 url 字段
|
||||
@@ -266,11 +372,6 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
const image = displayImages[0];
|
||||
if (!image) return null;
|
||||
|
||||
// 获取预览图 URL
|
||||
const previewUrl = usePreview && displayMode
|
||||
? getPreviewImageUrl(image as any, displayMode)
|
||||
: (image.uri || image.url || '');
|
||||
|
||||
return (
|
||||
<SingleImageItem
|
||||
image={image}
|
||||
@@ -278,6 +379,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
maxHeight={singleImageMaxHeight}
|
||||
borderRadiusValue={borderRadiusValue}
|
||||
onPress={() => handleImagePress(0)}
|
||||
usePreview={usePreview}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -285,7 +388,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
// 渲染横向双图
|
||||
const renderHorizontal = () => {
|
||||
return (
|
||||
<View style={[styles.horizontalContainer, { gap }]}>
|
||||
<View style={[gridStyles.horizontalContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const previewUrl = usePreview && displayMode
|
||||
? getPreviewImageUrl(image as any, displayMode)
|
||||
@@ -296,7 +399,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
gridStyles.horizontalItem,
|
||||
{
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
@@ -306,7 +409,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
@@ -320,7 +423,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
// 渲染网格布局
|
||||
const renderGrid = () => {
|
||||
return (
|
||||
<View style={[styles.gridContainer, { gap }]}>
|
||||
<View style={[gridStyles.gridContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const isLastVisible = index === displayImages.length - 1;
|
||||
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
||||
@@ -334,8 +437,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
|
||||
gridStyles.gridItem,
|
||||
gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2,
|
||||
{
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
@@ -343,13 +446,13 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{showOverlay && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{remainingCount}</Text>
|
||||
<View style={gridStyles.moreOverlay}>
|
||||
<Text style={gridStyles.moreText}>+{remainingCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -379,7 +482,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
|
||||
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
|
||||
return (
|
||||
<View style={[styles.masonryColumn, { gap }]}>
|
||||
<View style={[gridStyles.masonryColumn, { gap }]}>
|
||||
{columnImages.map((image, index) => {
|
||||
const actualIndex = columnIndex + index * 2;
|
||||
const aspectRatio = image.width && image.height
|
||||
@@ -396,7 +499,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
key={image.id || actualIndex}
|
||||
onPress={() => handleImagePress(actualIndex)}
|
||||
style={[
|
||||
styles.masonryItem,
|
||||
gridStyles.masonryItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
height: Math.max(height, itemWidth * 0.7),
|
||||
@@ -406,7 +509,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
@@ -418,7 +521,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.masonryContainer, { gap }]}>
|
||||
<View style={[gridStyles.masonryContainer, { gap }]}>
|
||||
{renderColumn(leftColumn, 0)}
|
||||
{renderColumn(rightColumn, 1)}
|
||||
</View>
|
||||
@@ -446,13 +549,15 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, style]}
|
||||
testID={testID}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
<ImageGridStylesContext.Provider value={gridStyles}>
|
||||
<View
|
||||
style={[gridStyles.container, style]}
|
||||
testID={testID}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</ImageGridStylesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -472,6 +577,8 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
borderRadius: borderRadiusValue = borderRadius.sm,
|
||||
...props
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
|
||||
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
|
||||
|
||||
const renderCompactGrid = () => {
|
||||
@@ -488,7 +595,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
<Pressable
|
||||
onPress={() => props.onImagePress?.(images, 0)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
gridStyles.compactItem,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -498,7 +605,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
@@ -511,13 +618,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
|
||||
|
||||
return (
|
||||
<View style={[styles.compactGrid, { gap }]}>
|
||||
<View style={[gridStyles.compactGrid, { gap }]}>
|
||||
{images.slice(0, 6).map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => props.onImagePress?.(images, index)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
gridStyles.compactItem,
|
||||
{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
@@ -527,13 +634,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{index === 5 && images.length > 6 && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{images.length - 6}</Text>
|
||||
<View style={gridStyles.moreOverlay}>
|
||||
<Text style={gridStyles.moreText}>+{images.length - 6}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -542,86 +649,11 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
|
||||
return (
|
||||
<ImageGridStylesContext.Provider value={gridStyles}>
|
||||
<View style={gridStyles.compactContainer}>{renderCompactGrid()}</View>
|
||||
</ImageGridStylesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
fullSize: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 单图样式
|
||||
singleContainer: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 横向布局样式
|
||||
horizontalContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
horizontalItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 网格布局样式
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
gridItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '48%', // 2列布局,每列约48%宽度,留有余量避免换行
|
||||
},
|
||||
gridItem3: {
|
||||
width: '31%', // 3列布局,每列约31%宽度,留有余量避免换行
|
||||
},
|
||||
// 瀑布流样式
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
masonryItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 更多遮罩 - 类似微博的灰色蒙版
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
moreText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 紧凑模式样式
|
||||
compactContainer: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
compactItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
});
|
||||
|
||||
// 导入字体大小
|
||||
import { fontSizes } from '../../theme';
|
||||
|
||||
export default ImageGrid;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 支持标签、错误提示、图标、多行输入等
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes } from '../../theme';
|
||||
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
interface InputProps {
|
||||
@@ -36,6 +36,46 @@ interface InputProps {
|
||||
autoCorrect?: boolean;
|
||||
}
|
||||
|
||||
function createInputStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 44,
|
||||
},
|
||||
multilineInput: {
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 100,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
error: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -55,6 +95,8 @@ const Input: React.FC<InputProps> = ({
|
||||
keyboardType = 'default',
|
||||
autoCorrect = true,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createInputStyles(colors), [colors]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const getBorderColor = () => {
|
||||
@@ -87,11 +129,7 @@ const Input: React.FC<InputProps> = ({
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
multiline && styles.multilineInput,
|
||||
inputStyle,
|
||||
]}
|
||||
style={[styles.input, multiline && styles.multilineInput, inputStyle]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
@@ -105,15 +143,11 @@ const Input: React.FC<InputProps> = ({
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
autoCorrect={autoCorrect}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={onRightIconPress}
|
||||
disabled={!onRightIconPress}
|
||||
>
|
||||
<TouchableOpacity onPress={onRightIconPress} disabled={!onRightIconPress}>
|
||||
<MaterialCommunityIcons
|
||||
name={rightIcon as any}
|
||||
size={20}
|
||||
@@ -132,43 +166,4 @@ const Input: React.FC<InputProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
// 减少非焦点状态的边框明显度
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 44,
|
||||
},
|
||||
multilineInput: {
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 100,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
error: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* 支持不同尺寸、全屏模式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors } from '../../theme';
|
||||
import { useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
type LoadingSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
@@ -16,12 +16,32 @@ interface LoadingProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createLoadingStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullScreen: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Loading: React.FC<LoadingProps> = ({
|
||||
size = 'md',
|
||||
color = colors.primary.main,
|
||||
color,
|
||||
fullScreen = false,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createLoadingStyles(colors), [colors]);
|
||||
const indicatorColor = color ?? colors.primary.main;
|
||||
|
||||
const getSize = (): 'small' | 'large' | undefined => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
@@ -36,30 +56,16 @@ const Loading: React.FC<LoadingProps> = ({
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<View style={[styles.fullScreen, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
<ActivityIndicator size={getSize()} color={indicatorColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
<ActivityIndicator size={getSize()} color={indicatorColor} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullScreen: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
|
||||
export default Loading;
|
||||
|
||||
137
src/components/common/SearchBar.tsx
Normal file
137
src/components/common/SearchBar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* SearchBar 搜索栏组件
|
||||
* 用于搜索内容
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function createSearchBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: colors.primary.main,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
placeholder = '搜索...',
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isFocused && styles.containerFocused]}>
|
||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={18}
|
||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={onSubmit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onChangeText('')}
|
||||
style={styles.clearButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -4,7 +4,7 @@
|
||||
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -13,10 +13,40 @@ import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
import { borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
function createSmartImageStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
variantImage: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 8,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: `${colors.background.paper}E6`,
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: `${colors.text.primary}0D`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 图片加载状态
|
||||
export type ImageLoadState = 'loading' | 'success' | 'error';
|
||||
@@ -61,6 +91,8 @@ export interface SmartImageProps {
|
||||
usePreview?: boolean;
|
||||
/** 是否懒加载 */
|
||||
lazyLoad?: boolean;
|
||||
/** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */
|
||||
lazyRootMargin?: string;
|
||||
/** 缓存策略 */
|
||||
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
|
||||
}
|
||||
@@ -85,11 +117,15 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
previewUrl,
|
||||
usePreview = false,
|
||||
lazyLoad = false,
|
||||
lazyRootMargin = '160px',
|
||||
cachePolicy = 'memory-disk',
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSmartImageStyles(colors), [colors]);
|
||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||
const [isVisible, setIsVisible] = useState(!lazyLoad);
|
||||
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
|
||||
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
|
||||
const lazyTargetRef = useRef<View>(null);
|
||||
|
||||
// 解析图片源 - 支持 uri 或 url 字段
|
||||
const imageUri = typeof source === 'string'
|
||||
@@ -99,6 +135,39 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
// 解析预览图 URL
|
||||
const previewUri = typeof previewUrl === 'string' ? previewUrl : '';
|
||||
|
||||
useEffect(() => {
|
||||
setShouldLoadOriginal(false);
|
||||
setLoadState('loading');
|
||||
}, [imageUri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lazyLoad) {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
if (Platform.OS !== 'web') {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
setIsVisible(false);
|
||||
const node = lazyTargetRef.current as unknown as Element | null;
|
||||
if (!node || typeof IntersectionObserver === 'undefined') {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries.some(e => e.isIntersecting)) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: lazyRootMargin }
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [lazyLoad, lazyRootMargin, imageUri]);
|
||||
|
||||
// 智能选择图片 URL
|
||||
const getImageUrl = useCallback((): string => {
|
||||
// 如果应该加载原图(预览图已加载完成或没有预览图)
|
||||
@@ -200,11 +269,13 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是懒加载且不可见,显示占位
|
||||
// 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载)
|
||||
if (lazyLoad && !isVisible) {
|
||||
return (
|
||||
<View
|
||||
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
|
||||
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
|
||||
collapsable={false}
|
||||
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.default }]}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
@@ -288,36 +359,15 @@ export const VariantImage: React.FC<ImageVariantProps> = ({
|
||||
<SmartImage
|
||||
{...props}
|
||||
style={finalStyle}
|
||||
imageStyle={[styles.variantImage, imageStyle]}
|
||||
imageStyle={[variantStyles.variantImage, imageStyle]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
const variantStyles = StyleSheet.create({
|
||||
variantImage: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 8,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
export default SmartImage;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||
import { colors, fontSizes } from '../../theme';
|
||||
import { useAppColors, fontSizes } from '../../theme';
|
||||
|
||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||
|
||||
@@ -60,6 +60,7 @@ const Text: React.FC<CustomTextProps> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const textStyle = [
|
||||
styles.base,
|
||||
variantStyles[variant],
|
||||
|
||||
@@ -16,6 +16,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer';
|
||||
export { default as ResponsiveGrid } from './ResponsiveGrid';
|
||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||
export { default as AppBackButton } from './AppBackButton';
|
||||
|
||||
// 图片相关组件
|
||||
export { default as SmartImage } from './SmartImage';
|
||||
|
||||
144
src/core/entities/Message.ts
Normal file
144
src/core/entities/Message.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Message Entity - 消息领域实体
|
||||
* 定义消息的核心属性和行为,不依赖任何外部框架
|
||||
*/
|
||||
|
||||
export interface MessageSegment {
|
||||
type: 'text' | 'image' | 'mention' | 'reply' | string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
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>): 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>): 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 '';
|
||||
}
|
||||
};
|
||||
290
src/core/entities/Post.ts
Normal file
290
src/core/entities/Post.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Post Entity - 帖子领域实体
|
||||
* 定义帖子的核心属性和行为,不依赖任何外部框架
|
||||
*/
|
||||
|
||||
// ==================== 帖子状态枚举 ====================
|
||||
|
||||
export type PostStatus = 'published' | 'draft' | 'deleted';
|
||||
|
||||
// ==================== 值对象 ====================
|
||||
|
||||
/**
|
||||
* 帖子作者信息
|
||||
*/
|
||||
export interface PostAuthor {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
is_following?: boolean;
|
||||
is_following_me?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子图片信息
|
||||
*/
|
||||
export interface PostImage {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子标签
|
||||
*/
|
||||
export interface PostTag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ==================== 主实体 ====================
|
||||
|
||||
/**
|
||||
* 帖子实体
|
||||
*/
|
||||
export interface Post {
|
||||
/** 帖子唯一标识 */
|
||||
id: string;
|
||||
/** 作者ID */
|
||||
authorId: string;
|
||||
/** 作者信息 */
|
||||
author?: PostAuthor;
|
||||
/** 帖子标题 */
|
||||
title: string;
|
||||
/** 帖子内容 */
|
||||
content: string;
|
||||
/** 图片列表 */
|
||||
images: PostImage[];
|
||||
/** 点赞数 (camelCase) */
|
||||
likesCount: number;
|
||||
/** 评论数 (camelCase) */
|
||||
commentsCount: number;
|
||||
/** 分享数 (camelCase) */
|
||||
sharesCount: number;
|
||||
/** 浏览数 (camelCase) */
|
||||
viewsCount: number;
|
||||
/** 收藏数 (camelCase) */
|
||||
favoritesCount: number;
|
||||
/** 当前用户是否已点赞 (camelCase) */
|
||||
isLiked: boolean;
|
||||
/** 当前用户是否已收藏 (camelCase) */
|
||||
isFavorited: boolean;
|
||||
/** 是否置顶 (camelCase) */
|
||||
isPinned: boolean;
|
||||
/** 帖子状态 */
|
||||
status: PostStatus;
|
||||
/** 所属频道ID */
|
||||
channelId?: string;
|
||||
/** 频道摘要(列表 API 填充,供 PostCard 等展示) */
|
||||
channel?: { id: string; name: string } | null;
|
||||
/** 标签列表 */
|
||||
tags: string[];
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
/** 更新时间 */
|
||||
updatedAt: string;
|
||||
/** 用户编辑内容的时间(与统计类更新解耦) */
|
||||
contentEditedAt?: string;
|
||||
|
||||
// ==================== 向后兼容字段 (snake_case) ====================
|
||||
// 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本
|
||||
|
||||
/** @deprecated 请使用 likesCount */
|
||||
likes_count?: number;
|
||||
/** @deprecated 请使用 commentsCount */
|
||||
comments_count?: number;
|
||||
/** @deprecated 请使用 favoritesCount */
|
||||
favorites_count?: number;
|
||||
/** @deprecated 请使用 sharesCount */
|
||||
shares_count?: number;
|
||||
/** @deprecated 请使用 viewsCount */
|
||||
views_count?: number;
|
||||
/** @deprecated 请使用 isLiked */
|
||||
is_liked?: boolean;
|
||||
/** @deprecated 请使用 isFavorited */
|
||||
is_favorited?: boolean;
|
||||
/** @deprecated 请使用 isPinned */
|
||||
is_pinned?: boolean;
|
||||
/** @deprecated 请使用 authorId */
|
||||
user_id?: string;
|
||||
/** @deprecated 请使用 createdAt */
|
||||
created_at?: string;
|
||||
/** @deprecated 请使用 updatedAt */
|
||||
updated_at?: string;
|
||||
/** @deprecated 请使用 contentEditedAt */
|
||||
content_edited_at?: string;
|
||||
/** @deprecated 请使用 channelId */
|
||||
channel_id?: string;
|
||||
/** @deprecated 请使用 status */
|
||||
status_str?: string;
|
||||
/** 投票帖子标识 (部分旧代码使用) */
|
||||
is_vote?: boolean;
|
||||
/** 锁定状态 (部分旧代码使用) */
|
||||
is_locked?: boolean;
|
||||
/** 置顶评论 (部分旧代码使用) */
|
||||
top_comment?: any;
|
||||
}
|
||||
|
||||
// ==================== 工厂函数 ====================
|
||||
|
||||
/**
|
||||
* 创建帖子作者信息
|
||||
*/
|
||||
export const createPostAuthor = (data: Partial<PostAuthor>): PostAuthor => ({
|
||||
id: data.id || '',
|
||||
username: data.username || '',
|
||||
nickname: data.nickname,
|
||||
avatar: data.avatar,
|
||||
is_following: data.is_following,
|
||||
is_following_me: data.is_following_me,
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建帖子图片信息
|
||||
*/
|
||||
export const createPostImage = (data: Partial<PostImage>): PostImage => ({
|
||||
url: data.url || '',
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
thumbnailUrl: data.thumbnailUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建帖子实体
|
||||
*/
|
||||
export const createPost = (data: Partial<Post>): Post => ({
|
||||
id: data.id || '',
|
||||
authorId: data.authorId || '',
|
||||
author: data.author,
|
||||
title: data.title || '',
|
||||
content: data.content || '',
|
||||
images: data.images || [],
|
||||
likesCount: data.likesCount || 0,
|
||||
commentsCount: data.commentsCount || 0,
|
||||
sharesCount: data.sharesCount || 0,
|
||||
viewsCount: data.viewsCount || 0,
|
||||
favoritesCount: data.favoritesCount || 0,
|
||||
isLiked: data.isLiked || false,
|
||||
isFavorited: data.isFavorited || false,
|
||||
isPinned: data.isPinned || false,
|
||||
status: data.status || 'published',
|
||||
channelId: data.channelId,
|
||||
tags: data.tags || [],
|
||||
createdAt: data.createdAt || new Date().toISOString(),
|
||||
updatedAt: data.updatedAt || new Date().toISOString(),
|
||||
});
|
||||
|
||||
// ==================== 类型守卫 ====================
|
||||
|
||||
/**
|
||||
* 检查是否为有效的帖子状态
|
||||
*/
|
||||
export const isValidPostStatus = (status: string): status is PostStatus => {
|
||||
return ['published', 'draft', 'deleted'].includes(status);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查帖子是否已发布
|
||||
*/
|
||||
export const isPostPublished = (post: Post): boolean => {
|
||||
return post.status === 'published';
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查帖子是否已删除
|
||||
*/
|
||||
export const isPostDeleted = (post: Post): boolean => {
|
||||
return post.status === 'deleted';
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查帖子是否为草稿
|
||||
*/
|
||||
export const isPostDraft = (post: Post): boolean => {
|
||||
return post.status === 'draft';
|
||||
};
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
/**
|
||||
* 检查当前用户是否为帖子作者
|
||||
*/
|
||||
export const isPostAuthor = (post: Post, userId: string): boolean => {
|
||||
return post.authorId === userId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子的主要图片(第一张图片)
|
||||
*/
|
||||
export const getPostThumbnail = (post: Post): PostImage | undefined => {
|
||||
return post.images[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查帖子是否包含图片
|
||||
*/
|
||||
export const hasPostImages = (post: Post): boolean => {
|
||||
return post.images.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子的显示标题(优先使用标题,否则使用内容摘要)
|
||||
*/
|
||||
export const getPostDisplayTitle = (post: Post, maxLength: number = 50): string => {
|
||||
if (post.title) {
|
||||
return post.title;
|
||||
}
|
||||
if (post.content.length > maxLength) {
|
||||
return post.content.substring(0, maxLength) + '...';
|
||||
}
|
||||
return post.content;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算帖子的互动总数
|
||||
*/
|
||||
export const getPostTotalEngagement = (post: Post): number => {
|
||||
return post.likesCount + post.commentsCount + post.sharesCount + post.favoritesCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据创建时间 ISO 字符串格式化为列表用相对时间(与历史 formatPostTime 行为一致)
|
||||
*/
|
||||
export const formatPostCreatedAtString = (createdAt: string | undefined | null): string => {
|
||||
if (!createdAt) return '';
|
||||
const createdAtDate = new Date(createdAt);
|
||||
if (Number.isNaN(createdAtDate.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - createdAtDate.getTime();
|
||||
if (diffMs < 0) {
|
||||
return createdAtDate.toLocaleDateString('zh-CN');
|
||||
}
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}分钟前`;
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}小时前`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
}
|
||||
return createdAtDate.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化帖子创建时间为相对时间描述
|
||||
*/
|
||||
export const formatPostTime = (post: Post): string => {
|
||||
return formatPostCreatedAtString(post.createdAt);
|
||||
};
|
||||
656
src/core/usecases/ProcessMessageUseCase.ts
Normal file
656
src/core/usecases/ProcessMessageUseCase.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* ProcessMessageUseCase - 处理消息用例
|
||||
* 处理消息的业务逻辑,协调 Repository 和 SSEClient
|
||||
* 纯业务逻辑,无UI依赖
|
||||
*/
|
||||
|
||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
|
||||
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<MessageUseCaseSubscriber> = new Set();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private processedMessageIds: Set<string> = new Set();
|
||||
private processedMessageIdsExpiry: Map<string, number> = new Map();
|
||||
private pendingReadMap: Map<string, ReadStateRecord> = new Map();
|
||||
private readStateVersion = 0;
|
||||
private pendingUserRequests: Map<string, Promise<any>> = 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.setupSSEListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁用例
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.subscribers.clear();
|
||||
this.processedMessageIds.clear();
|
||||
this.processedMessageIdsExpiry.clear();
|
||||
this.pendingReadMap.clear();
|
||||
this.pendingUserRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置SSE监听器
|
||||
*/
|
||||
private setupSSEListeners(): void {
|
||||
// 监听私聊消息
|
||||
const unsubChat = sseClient.on('chat', (message) => {
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = sseClient.on('group_message', (message) => {
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = sseClient.on('read', (message) => {
|
||||
this.handleReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = sseClient.on('group_read', (message) => {
|
||||
this.handleGroupReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = sseClient.on('recall', (message) => {
|
||||
this.handleRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
|
||||
this.handleGroupRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
|
||||
this.handleGroupTyping(message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
|
||||
this.handleGroupNotice(message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnected = sseClient.on('connected', () => {
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: true },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDisconnected = sseClient.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<void> {
|
||||
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<void> {
|
||||
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<any | null> {
|
||||
// 先检查本地缓存
|
||||
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<any | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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: message_id,
|
||||
seq,
|
||||
};
|
||||
|
||||
// 通知订阅者
|
||||
this.notifySubscribers({
|
||||
type: 'group_notice',
|
||||
payload: {
|
||||
notice,
|
||||
text: buildGroupNoticeText(notice),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记会话已读
|
||||
*/
|
||||
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
||||
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<Message | null> {
|
||||
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<Message[]> {
|
||||
return messageRepository.getMessagesByConversation(conversationId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史消息
|
||||
*/
|
||||
async getHistoryMessages(
|
||||
conversationId: string,
|
||||
beforeSeq: number,
|
||||
limit: number = 20
|
||||
): Promise<Message[]> {
|
||||
// 先从本地获取
|
||||
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<Message[]> {
|
||||
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;
|
||||
1036
src/core/usecases/ProcessPostUseCase.ts
Normal file
1036
src/core/usecases/ProcessPostUseCase.ts
Normal file
File diff suppressed because it is too large
Load Diff
98
src/data/datasources/ApiDataSource.ts
Normal file
98
src/data/datasources/ApiDataSource.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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<T>(url: string, params?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.get<T>(fullUrl, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'GET');
|
||||
}
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.post<T>(fullUrl, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'POST');
|
||||
}
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.put<T>(fullUrl, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'PUT');
|
||||
}
|
||||
}
|
||||
|
||||
async delete<T>(url: string, data?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.delete<T>(fullUrl, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'DELETE');
|
||||
}
|
||||
}
|
||||
|
||||
async upload<T>(
|
||||
url: string,
|
||||
file: { uri: string; name: string; type: string },
|
||||
additionalData?: Record<string, string>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.upload<T>(fullUrl, file, additionalData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'UPLOAD');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const apiDataSource = new ApiDataSource();
|
||||
199
src/data/datasources/CacheDataSource.ts
Normal file
199
src/data/datasources/CacheDataSource.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 缓存数据源实现
|
||||
* 基于内存和 AsyncStorage 的混合缓存实现
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ICacheDataSource, DataSourceError } from './interfaces';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
expiry: number | null; // null 表示永不过期
|
||||
}
|
||||
|
||||
export class CacheDataSource implements ICacheDataSource {
|
||||
private memoryCache: Map<string, CacheEntry<any>> = 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<any>): 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<T>(key: string): Promise<T | null> {
|
||||
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<T> = 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<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
try {
|
||||
const expiry = ttl === undefined
|
||||
? (this.defaultTtl > 0 ? Date.now() + this.defaultTtl : null)
|
||||
: (ttl > 0 ? Date.now() + ttl : null);
|
||||
|
||||
const entry: CacheEntry<T> = { 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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<any> = JSON.parse(stored);
|
||||
return !this.isExpired(entry);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMultiple<T>(keys: string[]): Promise<(T | null)[]> {
|
||||
return Promise.all(keys.map(key => this.get<T>(key)));
|
||||
}
|
||||
|
||||
async setMultiple<T>(entries: { key: string; value: T; ttl?: number }[]): Promise<void> {
|
||||
await Promise.all(entries.map(entry => this.set(entry.key, entry.value, entry.ttl)));
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const cacheDataSource = new CacheDataSource();
|
||||
323
src/data/datasources/LocalDataSource.ts
Normal file
323
src/data/datasources/LocalDataSource.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 本地数据库数据源实现
|
||||
* 封装所有 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<void> = 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;
|
||||
private initializingPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(config: LocalDataSourceConfig = {}) {
|
||||
// 如果提供了userId,使用用户专属数据库
|
||||
this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库连接
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized && this.db) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止并发初始化导致连接状态竞争
|
||||
if (this.initializingPromise) {
|
||||
await this.initializingPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.initializingPromise = this.doInitialize();
|
||||
try {
|
||||
await this.initializingPromise;
|
||||
} finally {
|
||||
this.initializingPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
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<void> {
|
||||
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)
|
||||
)`,
|
||||
// 帖子缓存表
|
||||
`CREATE TABLE IF NOT EXISTS posts_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
];
|
||||
|
||||
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<T>(sql: string, params?: any[]): Promise<T[]> {
|
||||
try {
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getAllAsync<T>(sql, params as any);
|
||||
}
|
||||
return await db.getAllAsync<T>(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'QUERY');
|
||||
}
|
||||
}
|
||||
|
||||
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
||||
try {
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getFirstAsync<T>(sql, params as any);
|
||||
}
|
||||
return await db.getFirstAsync<T>(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'GET_FIRST');
|
||||
}
|
||||
}
|
||||
|
||||
async execute(sql: string, params?: any[]): Promise<void> {
|
||||
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();
|
||||
if (params && params.length > 0) {
|
||||
return await db.runAsync(sql, params as any);
|
||||
}
|
||||
return await db.runAsync(sql);
|
||||
} catch (error) {
|
||||
if (this.isRecoverableError(error)) {
|
||||
this.db = null;
|
||||
this.initialized = false;
|
||||
dbInstance = null;
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.runAsync(sql, params as any);
|
||||
}
|
||||
return await db.runAsync(sql);
|
||||
}
|
||||
this.handleError(error, 'RUN');
|
||||
}
|
||||
}
|
||||
|
||||
async transaction(operations: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
const db = this.ensureDb();
|
||||
await db.withTransactionAsync(async () => {
|
||||
await operations();
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, 'TRANSACTION');
|
||||
}
|
||||
}
|
||||
|
||||
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await this.transaction(async () => {
|
||||
await operations(this);
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, 'BATCH');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 队列写入支持 ====================
|
||||
|
||||
/**
|
||||
* 使用队列执行写入操作(避免并发写入冲突)
|
||||
*/
|
||||
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
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();
|
||||
184
src/data/datasources/SSEClient.ts
Normal file
184
src/data/datasources/SSEClient.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* SSEClient - SSE连接管理
|
||||
* 只负责SSE连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
sseService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '../../services/sseService';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (data: T) => void;
|
||||
|
||||
// SSE事件类型
|
||||
export interface SSEEvents {
|
||||
'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 SSEEventType = keyof SSEEvents;
|
||||
|
||||
class SSEClient {
|
||||
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* 初始化SSE监听
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁SSE监听
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.handlers.clear();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
on<T extends SSEEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<SSEEvents[T]>
|
||||
): () => 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<T extends SSEEventType>(
|
||||
event: T,
|
||||
data: SSEEvents[T]
|
||||
): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return sseService.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动SSE连接
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
return sseService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开SSE连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
sseService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const sseClient = new SSEClient();
|
||||
export default sseClient;
|
||||
9
src/data/datasources/index.ts
Normal file
9
src/data/datasources/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 数据源导出
|
||||
* 统一导出所有数据源实现
|
||||
*/
|
||||
|
||||
export * from './interfaces';
|
||||
export * from './ApiDataSource';
|
||||
export * from './LocalDataSource';
|
||||
export * from './CacheDataSource';
|
||||
64
src/data/datasources/interfaces.ts
Normal file
64
src/data/datasources/interfaces.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 数据源接口定义
|
||||
* 定义所有数据源的标准接口,便于替换和测试
|
||||
*/
|
||||
|
||||
// API 数据源接口
|
||||
export interface IApiDataSource {
|
||||
get<T>(url: string, params?: any): Promise<T>;
|
||||
post<T>(url: string, data?: any): Promise<T>;
|
||||
put<T>(url: string, data?: any): Promise<T>;
|
||||
delete<T>(url: string, data?: any): Promise<T>;
|
||||
upload<T>(url: string, file: { uri: string; name: string; type: string }, additionalData?: Record<string, string>): Promise<T>;
|
||||
}
|
||||
|
||||
// 本地数据源接口 (SQLite)
|
||||
export interface ILocalDataSource {
|
||||
query<T>(sql: string, params?: any[]): Promise<T[]>;
|
||||
getFirst<T>(sql: string, params?: any[]): Promise<T | null>;
|
||||
execute(sql: string, params?: any[]): Promise<void>;
|
||||
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
||||
transaction(operations: () => Promise<void>): Promise<void>;
|
||||
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
||||
}
|
||||
|
||||
// 缓存数据源接口
|
||||
export interface ICacheDataSource {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
has(key: string): Promise<boolean>;
|
||||
getMultiple<T>(keys: string[]): Promise<(T | null)[]>;
|
||||
setMultiple<T>(entries: { key: string; value: T; ttl?: number }[]): Promise<void>;
|
||||
}
|
||||
|
||||
// 数据源错误类型
|
||||
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;
|
||||
};
|
||||
}
|
||||
302
src/data/mappers/ConversationMapper.ts
Normal file
302
src/data/mappers/ConversationMapper.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 会话数据映射器
|
||||
* 负责 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 ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射群组 API 响应
|
||||
*/
|
||||
private static mapGroupFromApi(group: GroupResponse): GroupModel {
|
||||
// 将数字类型的 join_type 转换为字符串类型
|
||||
// JoinType: 0 = 允许任何人, 1 = 需要审批, 2 = 不允许
|
||||
const mapJoinType = (joinType: number): 'anyone' | 'approval' | 'invite' => {
|
||||
switch (joinType) {
|
||||
case 0: return 'anyone';
|
||||
case 1: return 'approval';
|
||||
case 2: return 'invite';
|
||||
default: return 'approval';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(group.id || ''),
|
||||
name: group.name || '',
|
||||
avatar: group.avatar,
|
||||
description: group.description,
|
||||
// GroupResponse 没有 announcement 和 updated_at 字段,使用默认值
|
||||
announcement: undefined,
|
||||
ownerId: String(group.owner_id || ''),
|
||||
memberCount: group.member_count || 0,
|
||||
maxMemberCount: group.max_members || 500,
|
||||
joinType: mapJoinType(group.join_type),
|
||||
isMuted: group.mute_all || false,
|
||||
createdAt: new Date(group.created_at || Date.now()),
|
||||
updatedAt: new Date(group.created_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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user